From 113505de954327847b6e3f2f8993d580ac676c88 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 11:42:44 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.0d=20=E2=80=94=20add=20PR=20checks?= =?UTF-8?q?,=20iostreams,=20aliases,=20and=20broad=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling. --- .gitignore | 2 + README.md | 7 +- cmd/actions.go | 393 ++++++++------ cmd/aliases.go | 142 ++++++ cmd/api.go | 18 +- cmd/auth.go | 25 +- cmd/errors.go | 44 +- cmd/ios_init.go | 5 + cmd/issue.go | 370 ++++++++++++-- cmd/json.go | 147 +++++- cmd/label.go | 76 +-- cmd/milestone.go | 122 +++-- cmd/pr.go | 872 ++++++++++++++++++++++++++++++-- cmd/pr_checks.go | 99 ++++ cmd/pr_diff.go | 71 ++- cmd/pr_review.go | 31 +- cmd/release.go | 291 +++++++++-- cmd/repo.go | 210 ++++++-- cmd/root.go | 26 +- cmd/wiki.go | 117 +++-- go.mod | 6 +- go.sum | 6 + internal/api/client.go | 67 ++- internal/git/git.go | 42 ++ internal/iostreams/color.go | 77 +++ internal/iostreams/iostreams.go | 272 ++++++++++ internal/iostreams/table.go | 64 +++ internal/text/text.go | 70 +++ main.go | 1 + 29 files changed, 3131 insertions(+), 542 deletions(-) create mode 100644 cmd/aliases.go create mode 100644 cmd/ios_init.go create mode 100644 cmd/pr_checks.go create mode 100644 internal/iostreams/color.go create mode 100644 internal/iostreams/iostreams.go create mode 100644 internal/iostreams/table.go create mode 100644 internal/text/text.go diff --git a/.gitignore b/.gitignore index ec3f87e..07f46ec 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ config.yaml # Git worktrees .worktrees/ +# Workspace (scratch data, cloned repos, analysis) +.workspace/ diff --git a/README.md b/README.md index f8877a2..9da291f 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,11 @@ fgj repo edit owner/repo --public fgj repo edit owner/repo --private fgj repo edit owner/repo -d "New description" --homepage https://example.com fgj repo edit --default-branch develop +fgj repo edit owner/repo --name new-name + +# Rename a repository (shorthand) +fgj repo rename new-name +fgj repo rename new-name -R owner/old-name ``` ### Releases @@ -532,7 +537,7 @@ Contributions are welcome! Please feel free to submit a Pull Request at [forgejo - `pr checks`, `pr ready/draft` - `issue reopen`, `issue assign` - `release edit`, `release download`, `release generate-notes` -- `repo delete`, `repo rename` +- `repo delete` We welcome contributions to implement any of these features! diff --git a/cmd/actions.go b/cmd/actions.go index 5481c5c..fbb0d75 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -3,10 +3,8 @@ package cmd import ( "fmt" "net/http" - "os" "strconv" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" @@ -100,39 +98,67 @@ var runListCmd = &cobra.Command{ Use: "list", Short: "List recent workflow runs", Long: "List recent workflow runs for a repository.", - RunE: runRunList, + Example: ` # List recent workflow runs + fgj actions run list + + # List runs with a custom limit + fgj actions run list -L 50 + + # Output as JSON + fgj actions run list --json`, + 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, + Example: ` # View a workflow run + fgj actions run view 123 + + # View with job details + fgj actions run view 123 -v + + # View logs for a specific job + fgj actions run view 123 --job 456 --log + + # View only failed logs + fgj actions run view 123 --log-failed`, + Args: cobra.ExactArgs(1), + 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, + Example: ` # Watch a run until it completes + fgj actions run watch 123 + + # Watch with a custom polling interval + fgj actions run watch 123 -i 10s`, + 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, + Example: ` # Rerun a failed workflow run + fgj actions run rerun 123`, + 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, + Example: ` # Cancel a running workflow + fgj actions run cancel 123`, + Args: cobra.ExactArgs(1), + RunE: runRunCancel, } // Workflow commands @@ -146,39 +172,61 @@ var workflowListCmd = &cobra.Command{ Use: "list", Short: "List workflows", Long: "List all workflows in a repository.", - RunE: runWorkflowList, + Example: ` # List all workflows + fgj actions workflow list + + # List workflows as JSON + fgj actions workflow list --json + + # List workflows for a specific repo + fgj actions workflow list -R owner/repo`, + RunE: runWorkflowList, } var workflowViewCmd = &cobra.Command{ Use: "view ", Short: "View a workflow", Long: "View details about a specific workflow. You can specify the workflow by name or filename.", - Args: cobra.ExactArgs(1), - RunE: runWorkflowView, + Example: ` # View a workflow by filename + fgj actions workflow view ci.yml + + # View as JSON + fgj actions workflow view ci.yml --json`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowView, } var workflowRunCmd = &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.", - Args: cobra.ExactArgs(1), - RunE: runWorkflowRun, + Example: ` # Trigger a workflow on the default branch + fgj actions workflow run deploy.yml + + # Trigger on a specific branch with input parameters + fgj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`, + Args: cobra.ExactArgs(1), + 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, + Example: ` # Enable a workflow + fgj actions workflow enable ci.yml`, + 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, + Example: ` # Disable a workflow + fgj actions workflow disable ci.yml`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowDisable, } // Secret commands @@ -192,23 +240,35 @@ var actionsSecretListCmd = &cobra.Command{ Use: "list", Short: "List repository secrets", Long: "List all secrets for a repository.", - RunE: runActionsSecretList, + Example: ` # List all secrets + fgj actions secret list + + # List secrets for a specific repo + fgj 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.", - Args: cobra.ExactArgs(1), - RunE: runActionsSecretCreate, + Example: ` # Create a secret (will prompt for value) + fgj actions secret create DEPLOY_TOKEN + + # Create a secret for a specific repo + fgj 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.", - Args: cobra.ExactArgs(1), - RunE: runActionsSecretDelete, + Example: ` # Delete a secret + fgj actions secret delete DEPLOY_TOKEN`, + Args: cobra.ExactArgs(1), + RunE: runActionsSecretDelete, } // Variable commands @@ -222,39 +282,55 @@ var actionsVariableListCmd = &cobra.Command{ Use: "list", Short: "List repository variables", Long: "List all variables for a repository.", - RunE: runActionsVariableList, + Example: ` # List all variables + fgj actions variable list + + # List variables for a specific repo + fgj 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.", - Args: cobra.ExactArgs(1), - RunE: runActionsVariableGet, + Example: ` # Get a variable value + fgj 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.", - Args: cobra.ExactArgs(2), - RunE: runActionsVariableCreate, + Example: ` # Create a variable + fgj actions variable create ENVIRONMENT production + + # Create a variable for a specific repo + fgj 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.", - Args: cobra.ExactArgs(2), - RunE: runActionsVariableUpdate, + Example: ` # Update a variable + fgj 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.", - Args: cobra.ExactArgs(1), - RunE: runActionsVariableDelete, + Example: ` # Delete a variable + fgj actions variable delete ENVIRONMENT`, + Args: cobra.ExactArgs(1), + RunE: runActionsVariableDelete, } func init() { @@ -293,13 +369,13 @@ 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") + addJSONFlags(runListCmd, "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") + addJSONFlags(runViewCmd, "Output workflow run as JSON") addRepoFlags(runWatchCmd) runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") addRepoFlags(runRerunCmd) @@ -308,9 +384,9 @@ func init() { // 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") + addJSONFlags(workflowListCmd, "Output workflows as JSON") addRepoFlags(workflowViewCmd) - workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") + addJSONFlags(workflowViewCmd, "Output workflow as JSON") addRepoFlags(workflowRunCmd) addRepoFlags(workflowEnableCmd) addRepoFlags(workflowDisableCmd) @@ -325,6 +401,7 @@ func init() { // Add flags for variable commands addRepoFlags(actionsVariableListCmd) + addJSONFlags(actionsVariableListCmd, "Output variables as JSON") addRepoFlags(actionsVariableGetCmd) addRepoFlags(actionsVariableCreateCmd) addRepoFlags(actionsVariableUpdateCmd) @@ -364,39 +441,26 @@ 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 wantJSON(cmd) { + return outputJSON(cmd, runList.WorkflowRuns) } if len(runList.WorkflowRuns) == 0 { - fmt.Println("No workflow runs found") + fmt.Fprintln(ios.Out, "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) - } - + 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) - - 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) - } + tp.AddRow(formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, fmt.Sprintf("%d", run.ID), timeStr) } - - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runRunView(cmd *cobra.Command, args []string) error { @@ -425,7 +489,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") + jsonRequested := wantJSON(cmd) var jobID int64 if jobIDStr != "" { @@ -436,7 +500,7 @@ func runRunView(cmd *cobra.Command, args []string) error { } } - if jsonOutput && (showLog || showLogFailed) { + if jsonRequested && (showLog || showLogFailed) { return fmt.Errorf("--json cannot be used with --log or --log-failed") } @@ -450,7 +514,7 @@ func runRunView(cmd *cobra.Command, args []string) error { needsJobs := verbose || showLog || showLogFailed || jobID > 0 - if jsonOutput { + if jsonRequested { var runTasks []ActionTask if needsJobs { tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) @@ -487,33 +551,33 @@ func runRunView(cmd *cobra.Command, args []string) error { Run: run, Tasks: runTasks, } - return writeJSON(payload) + return outputJSON(cmd, payload) } // 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) + 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.Printf("Commit: %s\n", commit) + fmt.Fprintf(ios.Out, "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")) + 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.Printf("Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + 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.Printf("Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + 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 @@ -536,7 +600,7 @@ func runRunView(cmd *cobra.Command, args []string) error { } if len(runTasks) == 0 { - fmt.Println("\nNo jobs found for this run") + fmt.Fprintln(ios.Out, "\nNo jobs found for this run") return nil } @@ -557,14 +621,14 @@ func runRunView(cmd *cobra.Command, args []string) error { // Case 1: --verbose (show job steps/details without logs) if verbose && !showLog && !showLogFailed { - fmt.Println("\nJobs:") + fmt.Fprintln(ios.Out, "\nJobs:") for _, task := range runTasks { - fmt.Printf("\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) + 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.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + 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.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } return nil @@ -574,7 +638,7 @@ func runRunView(cmd *cobra.Command, args []string) error { if showLog || showLogFailed { for _, task := range runTasks { if err := showJobLog(client, owner, name, task, showLogFailed); err != nil { - fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) + fmt.Fprintf(ios.Out, "\nError fetching log for job %s: %v\n", task.Name, err) } } return nil @@ -583,15 +647,15 @@ func runRunView(cmd *cobra.Command, args []string) error { // Case 3: --job without --log or --verbose (show job details only) if jobID > 0 { task := runTasks[0] - fmt.Println("\nJob Details:") - fmt.Printf(" Name: %s\n", task.Name) - fmt.Printf(" ID: %d\n", task.ID) - fmt.Printf(" Status: %s\n", formatStatus(task.Status)) + 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.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + 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.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } @@ -635,12 +699,12 @@ func runRunWatch(cmd *cobra.Command, args []string) error { } if run.Status != lastStatus { - fmt.Printf("Status: %s\n", formatStatus(run.Status)) + fmt.Fprintf(ios.Out, "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)) + fmt.Fprintf(ios.Out, "Run #%d completed with status %s\n", run.IndexInRepo, formatStatus(run.Status)) return nil } @@ -675,7 +739,7 @@ func runRunRerun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to rerun workflow: %w", err) } - fmt.Printf("✓ Rerun requested for run %d\n", runID) + fmt.Fprintf(ios.Out, "✓ Rerun requested for run %d\n", runID) return nil } @@ -706,7 +770,7 @@ func runRunCancel(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to cancel workflow run: %w", err) } - fmt.Printf("✓ Cancel requested for run %d\n", runID) + fmt.Fprintf(ios.Out, "✓ Cancel requested for run %d\n", runID) return nil } @@ -715,10 +779,10 @@ func showJobLog(client *api.Client, owner, name string, task ActionTask, logFail 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) - fmt.Printf("Status: %s\n", formatStatus(task.Status)) - fmt.Printf("========================================\n\n") + 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) @@ -731,11 +795,11 @@ func showJobLog(client *api.Client, owner, name string, task ActionTask, logFail if logFailed { // TODO: Implement filtering for failed steps only // This would require parsing the log format and identifying failed step markers - fmt.Println("Note: --log-failed filtering not yet implemented, showing all logs") + fmt.Fprintln(ios.Out, "Note: --log-failed filtering not yet implemented, showing all logs") } - fmt.Print(logContent) - fmt.Println() + fmt.Fprint(ios.Out, logContent) + fmt.Fprintln(ios.Out) return nil } @@ -848,34 +912,25 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { } if len(allWorkflows) == 0 { - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(allWorkflows) + if wantJSON(cmd) { + return outputJSON(cmd, allWorkflows) } - fmt.Println("No workflows found") + fmt.Fprintln(ios.Out, "No workflows found") return nil } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(allWorkflows) + if wantJSON(cmd) { + return outputJSON(cmd, 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) - } + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "STATE", "PATH") for _, workflow := range allWorkflows { - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", - workflow.Name, workflow.State, workflow.Path); err != nil { - return fmt.Errorf("failed to write workflow: %w", err) - } + tp.AddRow(workflow.Name, workflow.State, workflow.Path) } - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runWorkflowView(cmd *cobra.Command, args []string) error { @@ -901,8 +956,6 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { return err } - jsonOutput, _ := cmd.Flags().GetBool("json") - var latestRun *ActionRun // Get the latest run for this workflow @@ -912,7 +965,7 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { latestRun = &runList.WorkflowRuns[0] } - if jsonOutput { + if wantJSON(cmd) { payload := struct { Workflow *Workflow `json:"workflow"` LatestRun *ActionRun `json:"latest_run,omitempty"` @@ -920,21 +973,21 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { Workflow: workflow, LatestRun: latestRun, } - return writeJSON(payload) + return outputJSON(cmd, payload) } // Display workflow information - fmt.Printf("Name: %s\n", workflow.Name) - fmt.Printf("Path: %s\n", workflow.Path) - fmt.Printf("State: %s\n", workflow.State) + 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.Printf("\nLatest run:\n") - fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) - fmt.Printf(" Event: %s\n", latestRun.Event) - fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) + 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.Printf(" Created: %s\n", formatTimeSince(createdTime)) + fmt.Fprintf(ios.Out, " Created: %s\n", formatTimeSince(createdTime)) } } @@ -1006,12 +1059,12 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to trigger workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' triggered successfully\n", workflowIdentifier) - fmt.Printf(" Branch/Tag: %s\n", ref) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' triggered successfully\n", workflowIdentifier) + fmt.Fprintf(ios.Out, " Branch/Tag: %s\n", ref) if len(inputs) > 0 { - fmt.Println(" Inputs:") + fmt.Fprintln(ios.Out, " Inputs:") for key, value := range inputs { - fmt.Printf(" %s: %s\n", key, value) + fmt.Fprintf(ios.Out, " %s: %s\n", key, value) } } @@ -1059,7 +1112,7 @@ func runWorkflowEnable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to enable workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' enabled\n", workflow.Name) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' enabled\n", workflow.Name) return nil } @@ -1104,7 +1157,7 @@ func runWorkflowDisable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to disable workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' disabled\n", workflow.Name) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' disabled\n", workflow.Name) return nil } @@ -1170,24 +1223,16 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error { } if len(secrets) == 0 { - fmt.Println("No secrets found") + fmt.Fprintln(ios.Out, "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) - } + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "CREATED") 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) - } + tp.AddRow(secret.Name, secret.Created.Format("2006-01-02 15:04:05")) } - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runActionsSecretCreate(cmd *cobra.Command, args []string) error { @@ -1210,7 +1255,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { secretName := args[0] // Read secret value from stdin - fmt.Print("Enter secret value: ") + fmt.Fprint(ios.ErrOut, "Enter secret value: ") var secretValue string _, err = fmt.Scanln(&secretValue) if err != nil { @@ -1227,7 +1272,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create secret: %w", err) } - fmt.Printf("Secret '%s' created successfully\n", secretName) + fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName) return nil } @@ -1255,15 +1300,57 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete secret: %w", err) } - fmt.Printf("Secret '%s' deleted successfully\n", secretName) + 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 { - // Note: The SDK doesn't have a ListRepoActionVariable method yet - return fmt.Errorf("listing variables is not yet supported in the SDK") + 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 + } + + 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 { @@ -1290,7 +1377,7 @@ func runActionsVariableGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get variable: %w", err) } - fmt.Printf("%s=%s\n", variable.Name, variable.Value) + fmt.Fprintf(ios.Out, "%s=%s\n", variable.Name, variable.Value) return nil } @@ -1319,7 +1406,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create variable: %w", err) } - fmt.Printf("Variable '%s' created successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' created successfully\n", variableName) return nil } @@ -1348,7 +1435,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update variable: %w", err) } - fmt.Printf("Variable '%s' updated successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' updated successfully\n", variableName) return nil } @@ -1376,6 +1463,6 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete variable: %w", err) } - fmt.Printf("Variable '%s' deleted successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' deleted successfully\n", variableName) return nil } diff --git a/cmd/aliases.go b/cmd/aliases.go new file mode 100644 index 0000000..10447e7 --- /dev/null +++ b/cmd/aliases.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" +) + +// Top-level aliases for "actions run" and "actions workflow" commands, +// matching gh CLI's command structure (e.g., "fgj run list" instead of "fgj actions run list"). + +func init() { + // --- run alias --- + runAliasCmd := &cobra.Command{ + Use: "run", + Short: "View and manage workflow runs (alias for 'actions run')", + Long: "List, view, and manage workflow runs.\n\nThis is a top-level alias for 'actions run'.", + } + + runAliasListCmd := &cobra.Command{ + Use: "list", + Short: "List recent workflow runs", + Long: "List recent workflow runs for a repository.", + RunE: runRunList, + } + addRepoFlags(runAliasListCmd) + runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") + + runAliasViewCmd := &cobra.Command{ + Use: "view ", + Short: "View a workflow run", + Long: "View details about a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunView, + } + addRepoFlags(runAliasViewCmd) + runAliasViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") + runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") + runAliasViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") + runAliasViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") + runAliasViewCmd.Flags().Bool("json", false, "Output workflow run as JSON") + + runAliasWatchCmd := &cobra.Command{ + Use: "watch ", + Short: "Watch a workflow run", + Long: "Poll a workflow run until it completes.", + Args: cobra.ExactArgs(1), + RunE: runRunWatch, + } + addRepoFlags(runAliasWatchCmd) + runAliasWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") + + runAliasRerunCmd := &cobra.Command{ + Use: "rerun ", + Short: "Rerun a workflow run", + Long: "Trigger a rerun for a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunRerun, + } + addRepoFlags(runAliasRerunCmd) + + runAliasCancelCmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a workflow run", + Long: "Cancel a running workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunCancel, + } + addRepoFlags(runAliasCancelCmd) + + runAliasCmd.AddCommand(runAliasListCmd) + runAliasCmd.AddCommand(runAliasViewCmd) + runAliasCmd.AddCommand(runAliasWatchCmd) + runAliasCmd.AddCommand(runAliasRerunCmd) + runAliasCmd.AddCommand(runAliasCancelCmd) + rootCmd.AddCommand(runAliasCmd) + + // --- workflow alias --- + workflowAliasCmd := &cobra.Command{ + Use: "workflow", + Short: "Manage workflows (alias for 'actions workflow')", + Long: "List, view, and run workflows.\n\nThis is a top-level alias for 'actions workflow'.", + } + + workflowAliasListCmd := &cobra.Command{ + Use: "list", + Short: "List workflows", + Long: "List all workflows in a repository.", + RunE: runWorkflowList, + } + addRepoFlags(workflowAliasListCmd) + workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON") + + workflowAliasViewCmd := &cobra.Command{ + Use: "view ", + Short: "View a workflow", + Long: "View details about a specific workflow. You can specify the workflow by name or filename.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowView, + } + addRepoFlags(workflowAliasViewCmd) + workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON") + + workflowAliasRunCmd := &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.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowRun, + } + addRepoFlags(workflowAliasRunCmd) + workflowAliasRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") + workflowAliasRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") + workflowAliasRunCmd.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)") + + workflowAliasEnableCmd := &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, + } + addRepoFlags(workflowAliasEnableCmd) + + workflowAliasDisableCmd := &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, + } + addRepoFlags(workflowAliasDisableCmd) + + workflowAliasCmd.AddCommand(workflowAliasListCmd) + workflowAliasCmd.AddCommand(workflowAliasViewCmd) + workflowAliasCmd.AddCommand(workflowAliasRunCmd) + workflowAliasCmd.AddCommand(workflowAliasEnableCmd) + workflowAliasCmd.AddCommand(workflowAliasDisableCmd) + rootCmd.AddCommand(workflowAliasCmd) +} diff --git a/cmd/api.go b/cmd/api.go index e6eee18..873f16b 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -171,8 +171,10 @@ func runAPI(cmd *cobra.Command, args []string) error { } // Execute request + ios.StartSpinner("Requesting...") httpClient := &http.Client{} resp, err := httpClient.Do(req) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to perform request: %w", err) } @@ -180,13 +182,13 @@ func runAPI(cmd *cobra.Command, args []string) error { // Print response headers if requested if include { - fmt.Fprintf(os.Stdout, "%s %s\n", resp.Proto, resp.Status) + fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status) for key, values := range resp.Header { for _, v := range values { - fmt.Fprintf(os.Stdout, "%s: %s\n", key, v) + fmt.Fprintf(ios.Out, "%s: %s\n", key, v) } } - fmt.Fprintln(os.Stdout) + fmt.Fprintln(ios.Out) } // Read response body @@ -198,12 +200,12 @@ func runAPI(cmd *cobra.Command, args []string) error { // Handle non-2xx status codes if resp.StatusCode < 200 || resp.StatusCode >= 300 { if !silent { - fmt.Fprint(os.Stderr, string(respBody)) + fmt.Fprint(ios.ErrOut, string(respBody)) if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' { - fmt.Fprintln(os.Stderr) + fmt.Fprintln(ios.ErrOut) } } - os.Exit(1) + return fmt.Errorf("API request failed with status %d", resp.StatusCode) } if silent || len(respBody) == 0 { @@ -215,14 +217,14 @@ func runAPI(cmd *cobra.Command, args []string) error { if strings.Contains(contentType, "json") || json.Valid(respBody) { var parsed any if err := json.Unmarshal(respBody, &parsed); err == nil { - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(ios.Out) enc.SetIndent("", " ") return enc.Encode(parsed) } } // Raw output for non-JSON responses - _, err = os.Stdout.Write(respBody) + _, err = ios.Out.Write(respBody) return err } diff --git a/cmd/auth.go b/cmd/auth.go index d65fbe1..832e14d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -68,7 +68,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { reader := bufio.NewReader(os.Stdin) if hostname == "" { - fmt.Print("Forgejo instance hostname (default: codeberg.org): ") + fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ") input, _ := reader.ReadString('\n') hostname = strings.TrimSpace(input) if hostname == "" { @@ -77,12 +77,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { } if token == "" { - fmt.Print("Personal access token: ") + fmt.Fprint(ios.ErrOut, "Personal access token: ") tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return fmt.Errorf("failed to read token: %w", err) } - fmt.Println() + fmt.Fprintln(ios.ErrOut) token = strings.TrimSpace(string(tokenBytes)) } @@ -95,7 +95,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create client: %w", err) } + ios.StartSpinner("Authenticating...") user, _, err := client.GetMyUserInfo() + ios.StopSpinner() if err != nil { return fmt.Errorf("authentication failed: %w", err) } @@ -116,7 +118,8 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to save config: %w", err) } - fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Authenticated as %s on %s\n", cs.SuccessIcon(), user.UserName, hostname) return nil } @@ -128,14 +131,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) error { } if len(cfg.Hosts) == 0 { - fmt.Println("Not authenticated with any Forgejo instances") - fmt.Println("Run 'fgj auth login' to authenticate") + fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances") + fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate") return nil } - fmt.Println("Authenticated instances:") + fmt.Fprintln(ios.Out, "Authenticated instances:") for hostname, host := range cfg.Hosts { - fmt.Printf(" • %s (user: %s)\n", hostname, host.User) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, " %s %s (user: %s)\n", cs.SuccessIcon(), hostname, host.User) } return nil @@ -158,7 +162,8 @@ func runAuthLogout(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to save config: %w", err) } - fmt.Printf("✓ Logged out from %s\n", resolved) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Logged out from %s\n", cs.SuccessIcon(), resolved) return nil } @@ -174,7 +179,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error { return err } - fmt.Println(cfg.Hosts[resolved].Token) + fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token) return nil } diff --git a/cmd/errors.go b/cmd/errors.go index 422cd33..45ae602 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -3,7 +3,8 @@ package cmd import ( "encoding/json" "errors" - "os" + "fmt" + "strings" "forgejo.zerova.net/sid/fgj-sid/internal/api" ) @@ -40,6 +41,45 @@ func NewAPIError(status int, message string) *CLIError { return &CLIError{Code: ErrAPIError, Message: message, Status: status} } +// ContextualError wraps common errors with helpful hints. +func ContextualError(err error) error { + if err == nil { + return nil + } + + msg := err.Error() + + // Check for API errors with status codes + var apiErr *api.APIError + if errors.As(err, &apiErr) { + switch { + case apiErr.StatusCode == 401 || apiErr.StatusCode == 403: + return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err) + case apiErr.StatusCode == 404: + return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err) + } + return err + } + + // Check for network/connection errors + switch { + case strings.Contains(msg, "no such host"): + return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) + case strings.Contains(msg, "connection refused"): + return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) + } + + // Check for string-based status code patterns (from wrapped errors) + switch { + case strings.Contains(msg, "401") || strings.Contains(msg, "403"): + if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") { + return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err) + } + } + + return err +} + // writeJSONError writes a structured JSON error to stderr. // It attempts to extract structured info from known error types. // WriteJSONError writes a structured JSON error to stderr. @@ -70,7 +110,7 @@ func WriteJSONError(err error) { } } - enc := json.NewEncoder(os.Stderr) + enc := json.NewEncoder(ios.ErrOut) enc.SetIndent("", " ") _ = enc.Encode(cliErr) } diff --git a/cmd/ios_init.go b/cmd/ios_init.go new file mode 100644 index 0000000..f4846e5 --- /dev/null +++ b/cmd/ios_init.go @@ -0,0 +1,5 @@ +package cmd + +import "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" + +var ios = iostreams.New() diff --git a/cmd/issue.go b/cmd/issue.go index 42ef982..df752d4 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -3,14 +3,12 @@ package cmd import ( "fmt" "net/http" - "os" - "strconv" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -24,46 +22,114 @@ var issueListCmd = &cobra.Command{ Use: "list [flags]", Short: "List issues", Long: "List issues in a repository.", - RunE: runIssueList, + Example: ` # List open issues + fgj issue list + + # List closed issues for a specific repo + fgj issue list -s closed -R owner/repo + + # Output as JSON + fgj issue list --json`, + RunE: runIssueList, } var issueViewCmd = &cobra.Command{ Use: "view ", Short: "View an issue", Long: "Display detailed information about an issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueView, + Example: ` # View issue #42 + fgj issue view 42 + + # View using URL + fgj issue view https://codeberg.org/owner/repo/issues/42 + + # Open in browser + fgj issue view 42 --web + + # View an issue from a specific repo as JSON + fgj issue view 42 -R owner/repo --json`, + Args: cobra.ExactArgs(1), + RunE: runIssueView, } var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create an issue", Long: "Create a new issue.", - RunE: runIssueCreate, + Example: ` # Create an issue with a title + fgj issue create -t "Fix login bug" + + # Create an issue with title, body, and labels + fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`, + RunE: runIssueCreate, } var issueCommentCmd = &cobra.Command{ Use: "comment ", Short: "Add a comment to an issue", Long: "Add a comment to an existing issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueComment, + Example: ` # Add a comment to issue #42 + fgj issue comment 42 -b "This is fixed in the latest release" + + # Comment on an issue in a specific repo + fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runIssueComment, } var issueCloseCmd = &cobra.Command{ Use: "close ", Short: "Close an issue", Long: "Close an existing issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueClose, + Example: ` # Close issue #42 + fgj issue close 42 + + # Close with a comment + fgj issue close 42 -c "Fixed in commit abc1234"`, + Args: cobra.ExactArgs(1), + RunE: runIssueClose, +} + +var issueReopenCmd = &cobra.Command{ + Use: "reopen ", + Short: "Reopen an issue", + Long: "Reopen a closed issue.", + Example: ` # Reopen issue #42 + fgj issue reopen 42`, + Args: cobra.ExactArgs(1), + RunE: runIssueReopen, +} + +var issueDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an issue", + Long: "Delete an issue permanently.", + Example: ` # Delete issue #42 + fgj issue delete 42 + + # Delete without confirmation + fgj issue delete 42 -y`, + Args: cobra.ExactArgs(1), + RunE: runIssueDelete, } var issueEditCmd = &cobra.Command{ Use: "edit ", Short: "Edit an issue", Long: "Edit an existing issue's title, body, or state.", - Args: cobra.ExactArgs(1), - RunE: runIssueEdit, + Example: ` # Update the title of issue #42 + fgj issue edit 42 -t "Updated title" + + # Reopen a closed issue + fgj issue edit 42 -s open + + # Add and remove labels + fgj issue edit 42 --add-label bug --remove-label wontfix + + # Add a dependency + fgj issue edit 42 --add-dependency 10`, + Args: cobra.ExactArgs(1), + RunE: runIssueEdit, } func init() { @@ -73,19 +139,31 @@ func init() { issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueCommentCmd) issueCmd.AddCommand(issueCloseCmd) + issueCmd.AddCommand(issueReopenCmd) + issueCmd.AddCommand(issueDeleteCmd) issueCmd.AddCommand(issueEditCmd) + issueReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + 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") + issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") + issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username") + issueListCmd.Flags().String("author", "", "Filter by author username") + issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") + issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter") + addJSONFlags(issueListCmd, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - issueViewCmd.Flags().Bool("json", false, "Output issue as JSON") + addJSONFlags(issueViewCmd, "Output issue as JSON") + issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue") issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)") + issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.") + issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue") issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") @@ -93,6 +171,9 @@ func init() { issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") + issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue") @@ -106,6 +187,11 @@ func init() { func runIssueList(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") state, _ := cmd.Flags().GetString("state") + limit, _ := cmd.Flags().GetInt("limit") + assignee, _ := cmd.Flags().GetString("assignee") + author, _ := cmd.Flags().GetString("author") + labels, _ := cmd.Flags().GetStringSlice("label") + search, _ := cmd.Flags().GetString("search") owner, name, err := parseRepo(repo) if err != nil { @@ -134,9 +220,16 @@ func runIssueList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } + ios.StartSpinner("Fetching issues...") issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{ - State: stateType, + State: stateType, + Labels: labels, + KeyWord: search, + CreatedBy: author, + AssignedBy: assignee, + ListOptions: gitea.ListOptions{PageSize: limit}, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list issues: %w", err) } @@ -148,28 +241,26 @@ func runIssueList(cmd *cobra.Command, args []string) error { } } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(nonPRIssues) + if wantJSON(cmd) { + return outputJSON(cmd, nonPRIssues) } if len(nonPRIssues) == 0 { - fmt.Printf("No %s issues in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "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") + tp := ios.NewTablePrinter() + tp.AddHeader("NUMBER", "TITLE", "STATE") for _, issue := range nonPRIssues { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) + tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State)) } - _ = w.Flush() - - return nil + return tp.Render() } func runIssueView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -189,8 +280,10 @@ func runIssueView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching issue...") issue, _, err := client.GetIssue(owner, name, issueNumber) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get issue: %w", err) } @@ -199,8 +292,13 @@ func runIssueView(cmd *cobra.Command, args []string) error { if err != nil { comments = nil } + ios.StopSpinner() - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(issue.HTMLURL) + } + + if wantJSON(cmd) { payload := struct { Issue *gitea.Issue `json:"issue"` Comments []*gitea.Comment `json:"comments,omitempty"` @@ -208,26 +306,34 @@ func runIssueView(cmd *cobra.Command, args []string) error { Issue: issue, Comments: comments, } - return writeJSON(payload) + return outputJSON(cmd, payload) } - fmt.Printf("Issue #%d\n", issue.Index) - fmt.Printf("Title: %s\n", issue.Title) - fmt.Printf("State: %s\n", issue.State) - fmt.Printf("Author: %s\n", issue.Poster.UserName) - fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05")) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", issue.State) + fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY)) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY)) if issue.Body != "" { - fmt.Printf("\n%s\n", issue.Body) + fmt.Fprintf(ios.Out, "\n%s\n", issue.Body) } if len(comments) > 0 { - fmt.Printf("\nComments (%d):\n", len(comments)) + fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments)) for _, comment := range comments { - fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", + fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n", comment.Poster.FullName, comment.Poster.UserName, - comment.Created.Format("2006-01-02 15:04:05"), + text.FormatDate(comment.Created, isTTY), comment.Body) } } @@ -240,14 +346,28 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { title, _ := cmd.Flags().GetString("title") body, _ := cmd.Flags().GetString("body") labelNames, _ := cmd.Flags().GetStringSlice("label") + assignees, _ := cmd.Flags().GetStringSlice("assignee") + milestoneName, _ := cmd.Flags().GetString("milestone") owner, name, err := parseRepo(repo) if err != nil { return err } - if title == "" { - return fmt.Errorf("title is required") + // Interactive mode: prompt for missing fields when TTY + if title == "" && ios.IsStdinTTY() { + title, err = promptLine("Title: ") + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("title is required") + } + if body == "" { + body, _ = promptLine("Body (optional): ") + } + } else if title == "" { + return fmt.Errorf("title is required (use -t flag)") } cfg, err := config.Load() @@ -268,17 +388,56 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { } } + // Resolve @me in assignees + resolvedAssignees := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + if assignee == "@me" { + user, _, userErr := client.GetMyUserInfo() + if userErr != nil { + return fmt.Errorf("failed to get current user info: %w", userErr) + } + resolvedAssignees = append(resolvedAssignees, user.UserName) + } else { + resolvedAssignees = append(resolvedAssignees, assignee) + } + } + + // Resolve milestone name to ID + var milestoneID int64 + if milestoneName != "" { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + found := false + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + found = true + break + } + } + if !found { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + } + + ios.StartSpinner("Creating issue...") issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{ - Title: title, - Body: body, - Labels: labelIDs, + Title: title, + Body: body, + Labels: labelIDs, + Assignees: resolvedAssignees, + Milestone: milestoneID, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create issue: %w", err) } - fmt.Printf("Issue created: #%d\n", issue.Index) - fmt.Printf("View at: %s\n", issue.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index) + fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL) return nil } @@ -286,7 +445,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { func runIssueComment(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") body, _ := cmd.Flags().GetString("body") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -310,15 +469,18 @@ func runIssueComment(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Adding comment...") comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } - fmt.Printf("Comment added to issue #%d\n", issueNumber) - fmt.Printf("View at: %s\n", comment.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber) + fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL) return nil } @@ -326,7 +488,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { func runIssueClose(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") commentBody, _ := cmd.Flags().GetString("comment") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -347,23 +509,28 @@ func runIssueClose(cmd *cobra.Command, args []string) error { } if commentBody != "" { + ios.StartSpinner("Adding comment...") _, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: commentBody, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } } + ios.StartSpinner("Closing issue...") stateClosed := gitea.StateClosed _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ State: &stateClosed, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to close issue: %w", err) } - fmt.Printf("Issue #%d closed\n", issueNumber) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber) return nil } @@ -378,7 +545,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency") removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -425,9 +592,12 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { } } + ios.StartSpinner("Updating issue...") + if title != "" || body != "" || stateStr != "" { _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to edit issue: %w", err) } } @@ -435,12 +605,14 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if len(addLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames) if err != nil { + ios.StopSpinner() return err } _, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{ Labels: labelIDs, }) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to add labels: %w", err) } } @@ -448,16 +620,20 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if len(removeLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames) if err != nil { + ios.StopSpinner() return err } for _, labelID := range labelIDs { _, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to remove label %d: %w", labelID, err) } } } + ios.StopSpinner() + for _, depNumber := range addDeps { depIssue, _, err := client.GetIssue(owner, name, depNumber) if err != nil { @@ -469,7 +645,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err) } - fmt.Printf("Added dependency: #%d depends on #%d\n", issueNumber, depNumber) + fmt.Fprintf(ios.Out, "Added dependency: #%d depends on #%d\n", issueNumber, depNumber) } for _, depNumber := range removeDeps { @@ -483,10 +659,96 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err) } - fmt.Printf("Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) + fmt.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) } - fmt.Printf("Issue #%d updated\n", issueNumber) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber) + + return nil +} + +func runIssueDelete(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") + + issueNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + if !yes { + confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name)) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting issue...") + _, err = client.DeleteIssue(owner, name, issueNumber) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to delete issue: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name) + return nil +} + +func runIssueReopen(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + issueNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Reopening issue...") + stateOpen := gitea.StateOpen + _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ + State: &stateOpen, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to reopen issue: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), issueNumber) return nil } diff --git a/cmd/json.go b/cmd/json.go index 21f1b25..2472449 100644 --- a/cmd/json.go +++ b/cmd/json.go @@ -2,11 +2,154 @@ package cmd import ( "encoding/json" - "os" + "fmt" + "strings" + + "github.com/itchyny/gojq" + "github.com/spf13/cobra" ) +// addJSONFlags adds --json, --json-fields, and --jq flags to a command. +// --json is an optional-value string flag: +// - --json (no value) → output all fields as JSON +// - --json title,state → output only those fields (gh-compatible) +// +// --json-fields is kept as a backwards-compatible alias. +func addJSONFlags(cmd *cobra.Command, jsonDesc string) { + f := cmd.Flags() + f.String("json", "", jsonDesc) + f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value + f.String("json-fields", "", "Comma-separated list of JSON fields to include") + f.String("jq", "", "Filter JSON output using a jq expression") +} + +// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq. +func wantJSON(cmd *cobra.Command) bool { + if j, _ := cmd.Flags().GetString("json"); j != "" { + return true + } + if jq, _ := cmd.Flags().GetString("jq"); jq != "" { + return true + } + if f, _ := cmd.Flags().GetString("json-fields"); f != "" { + return true + } + return false +} + +// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags. +func outputJSON(cmd *cobra.Command, value any) error { + jsonVal, _ := cmd.Flags().GetString("json") + jsonFields, _ := cmd.Flags().GetString("json-fields") + jqExpr, _ := cmd.Flags().GetString("jq") + + fields := "" + jsonVal = strings.TrimSpace(jsonVal) + if jsonVal != "" { + fields = jsonVal + } else if jsonFields != "" { + fields = jsonFields + } + + return writeJSONFiltered(value, fields, jqExpr) +} + +// writeJSON writes a value as pretty-printed JSON to ios.Out. func writeJSON(value any) error { - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(ios.Out) enc.SetIndent("", " ") return enc.Encode(value) } + +// writeJSONFiltered writes a value as JSON, optionally selecting specific fields +// and/or applying a jq expression. If fields is empty and jqExpr is empty, it +// writes the full value. +func writeJSONFiltered(value any, fields string, jqExpr string) error { + // If no filtering, just write the full JSON. + if fields == "" && jqExpr == "" { + return writeJSON(value) + } + + // Convert value to a generic interface via JSON round-trip so we can + // manipulate it with maps/slices. + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling JSON: %w", err) + } + + var data any + if err := json.Unmarshal(raw, &data); err != nil { + return fmt.Errorf("unmarshaling JSON: %w", err) + } + + // Apply field selection if specified. + if fields != "" { + fieldList := strings.Split(fields, ",") + for i, f := range fieldList { + fieldList[i] = strings.TrimSpace(f) + } + data = selectFields(data, fieldList) + } + + // Apply jq expression if specified. + if jqExpr != "" { + return applyJQ(data, jqExpr) + } + + return writeJSON(data) +} + +// selectFields filters a JSON value to only include the specified fields. +// Works on both single objects and arrays of objects. +func selectFields(data any, fields []string) any { + switch v := data.(type) { + case []any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = selectFields(item, fields) + } + return result + case map[string]any: + result := make(map[string]any) + for _, field := range fields { + if val, ok := v[field]; ok { + result[field] = val + } + } + return result + default: + return data + } +} + +// applyJQ applies a jq expression to data and writes each output value. +func applyJQ(data any, expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return fmt.Errorf("invalid jq expression: %w", err) + } + + iter := query.Run(data) + enc := json.NewEncoder(ios.Out) + enc.SetIndent("", " ") + + for { + v, ok := iter.Next() + if !ok { + break + } + if err, isErr := v.(error); isErr { + return fmt.Errorf("jq error: %w", err) + } + // For string values, print raw (no JSON encoding) to match jq behavior. + if s, ok := v.(string); ok { + fmt.Fprintln(ios.Out, s) + } else { + if err := enc.Encode(v); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/label.go b/cmd/label.go index b5d1205..83966d8 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -2,9 +2,7 @@ package cmd import ( "fmt" - "os" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" @@ -72,6 +70,9 @@ var labelDeleteCmd = &cobra.Command{ Example: ` # Delete a label fgj label delete bug + # Delete without confirmation + fgj label delete bug -y + # Delete a label from a specific repository fgj label delete bug -R owner/repo`, Args: cobra.ExactArgs(1), @@ -86,20 +87,21 @@ func init() { labelCmd.AddCommand(labelDeleteCmd) labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - labelListCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelListCmd, "Output as JSON") labelCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)") labelCreateCmd.Flags().StringP("description", "d", "", "Label description") - labelCreateCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelCreateCmd, "Output as JSON") labelEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") labelEditCmd.Flags().String("name", "", "New name for the label") labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)") labelEditCmd.Flags().StringP("description", "d", "", "New description") - labelEditCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelEditCmd, "Output as JSON") labelDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + labelDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) { @@ -144,29 +146,28 @@ func runLabelList(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching labels...") labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list labels: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(labels) + if wantJSON(cmd) { + return outputJSON(cmd, labels) } if len(labels) == 0 { - fmt.Println("No labels found") + fmt.Fprintln(ios.Out, "No labels found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tCOLOR\tDESCRIPTION\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "COLOR", "DESCRIPTION") for _, l := range labels { - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", l.Name, l.Color, l.Description) + tp.AddRow(l.Name, l.Color, l.Description) } - _ = w.Flush() - - return nil + return tp.Render() } func runLabelCreate(cmd *cobra.Command, args []string) error { @@ -179,21 +180,23 @@ func runLabelCreate(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Creating label...") label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{ Name: labelName, Color: color, Description: description, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create label: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(label) + if wantJSON(cmd) { + return outputJSON(cmd, label) } - fmt.Printf("Label created: %s\n", label.Name) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label created: %s\n", cs.SuccessIcon(), label.Name) return nil } @@ -205,7 +208,9 @@ func runLabelEdit(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching label...") existing, err := findLabelByName(client, owner, name, labelName) + ios.StopSpinner() if err != nil { return err } @@ -233,46 +238,57 @@ func runLabelEdit(cmd *cobra.Command, args []string) error { return fmt.Errorf("no changes specified; use flags like --name, --color, or --description") } + ios.StartSpinner("Updating label...") label, _, err := client.EditLabel(owner, name, existing.ID, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit label: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(label) + if wantJSON(cmd) { + return outputJSON(cmd, label) } - fmt.Printf("Label updated: %s\n", label.Name) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label updated: %s\n", cs.SuccessIcon(), label.Name) return nil } func runLabelDelete(cmd *cobra.Command, args []string) error { labelName := args[0] + yes, _ := cmd.Flags().GetBool("yes") client, owner, name, err := newLabelClient(cmd) if err != nil { return err } + ios.StartSpinner("Fetching label...") existing, err := findLabelByName(client, owner, name, labelName) + ios.StopSpinner() if err != nil { return err } - fmt.Printf("Are you sure you want to delete label %q? (y/N): ", labelName) - var confirm string - _, _ = fmt.Scanln(&confirm) - if strings.ToLower(confirm) != "y" { - fmt.Println("Aborted") - return nil + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete label %q?", labelName)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } } + ios.StartSpinner("Deleting label...") _, err = client.DeleteLabel(owner, name, existing.ID) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete label: %w", err) } - fmt.Printf("Label deleted: %s\n", labelName) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label deleted: %s\n", cs.SuccessIcon(), labelName) return nil } diff --git a/cmd/milestone.go b/cmd/milestone.go index b8d156b..2674a3f 100644 --- a/cmd/milestone.go +++ b/cmd/milestone.go @@ -2,15 +2,14 @@ package cmd import ( "fmt" - "os" "strconv" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -45,6 +44,9 @@ var milestoneViewCmd = &cobra.Command{ # View by title fgj milestone view "v1.0" + # Open in browser + fgj milestone view "v1.0" --web + # Output as JSON fgj milestone view "v1.0" --json`, Args: cobra.ExactArgs(1), @@ -91,7 +93,10 @@ var milestoneDeleteCmd = &cobra.Command{ fgj milestone delete "v1.0" # Delete by ID - fgj milestone delete 1`, + fgj milestone delete 1 + + # Delete without confirmation + fgj milestone delete "v1.0" -y`, Args: cobra.ExactArgs(1), RunE: runMilestoneDelete, } @@ -106,24 +111,26 @@ func init() { milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all") - milestoneListCmd.Flags().Bool("json", false, "Output milestones as JSON") + addJSONFlags(milestoneListCmd, "Output milestones as JSON") milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - milestoneViewCmd.Flags().Bool("json", false, "Output milestone as JSON") + addJSONFlags(milestoneViewCmd, "Output milestone as JSON") + milestoneViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") milestoneCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone") milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") - milestoneCreateCmd.Flags().Bool("json", false, "Output created milestone as JSON") + addJSONFlags(milestoneCreateCmd, "Output created milestone as JSON") milestoneEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneEditCmd.Flags().String("title", "", "New title for the milestone") milestoneEditCmd.Flags().StringP("description", "d", "", "New description for the milestone") milestoneEditCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") milestoneEditCmd.Flags().String("state", "", "New state: open or closed") - milestoneEditCmd.Flags().Bool("json", false, "Output updated milestone as JSON") + addJSONFlags(milestoneEditCmd, "Output updated milestone as JSON") milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + milestoneDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } // resolveMilestone resolves a title-or-id argument to a milestone. @@ -193,35 +200,40 @@ func runMilestoneList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } + ios.StartSpinner("Fetching milestones...") milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ State: stateType, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list milestones: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(milestones) + if wantJSON(cmd) { + return outputJSON(cmd, milestones) } if len(milestones) == 0 { - fmt.Printf("No %s milestones in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "No %s milestones in %s/%s\n", state, owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n") + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES") for _, ms := range milestones { due := "" if ms.Deadline != nil { due = ms.Deadline.Format("2006-01-02") } - _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n", - ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues) + tp.AddRow( + fmt.Sprintf("%d", ms.ID), + ms.Title, + string(ms.State), + due, + fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues), + ) } - _ = w.Flush() - - return nil + return tp.Render() } func runMilestoneView(cmd *cobra.Command, args []string) error { @@ -242,32 +254,45 @@ func runMilestoneView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(ms) + if web, _ := cmd.Flags().GetBool("web"); web { + // Milestones don't have HTMLURL in the API, construct it + cfg2, _ := config.Load() + host, _ := cfg2.GetHost("", getDetectedHost()) + url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID) + return ios.OpenInBrowser(url) } - fmt.Printf("ID: %d\n", ms.ID) - fmt.Printf("Title: %s\n", ms.Title) - fmt.Printf("State: %s\n", ms.State) + if wantJSON(cmd) { + return outputJSON(cmd, ms) + } + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "ID: %d\n", ms.ID) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(ms.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", ms.State) if ms.Description != "" { - fmt.Printf("Description: %s\n", ms.Description) + fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description) } if ms.Deadline != nil { - fmt.Printf("Due Date: %s\n", ms.Deadline.Format("2006-01-02")) + fmt.Fprintf(ios.Out, "Due Date: %s\n", ms.Deadline.Format("2006-01-02")) } - fmt.Printf("Open Issues: %d\n", ms.OpenIssues) - fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues) - fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues) + fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY)) if ms.Updated != nil { - fmt.Printf("Updated: %s\n", ms.Updated.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY)) } if ms.Closed != nil { - fmt.Printf("Closed: %s\n", ms.Closed.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY)) } return nil @@ -308,16 +333,19 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error { opt.Deadline = deadline } + ios.StartSpinner("Creating milestone...") ms, _, err := client.CreateMilestone(owner, name, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create milestone: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(ms) + if wantJSON(cmd) { + return outputJSON(cmd, ms) } - fmt.Printf("Milestone created: %s\n", ms.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title) return nil } @@ -340,7 +368,9 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } @@ -389,22 +419,26 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error { return fmt.Errorf("no changes specified; use flags like --title, --description, --due, or --state") } + ios.StartSpinner("Updating milestone...") updated, _, err := client.EditMilestone(owner, name, ms.ID, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit milestone: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(updated) + if wantJSON(cmd) { + return outputJSON(cmd, updated) } - fmt.Printf("Milestone updated: %s\n", updated.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), updated.Title) return nil } func runMilestoneDelete(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") owner, name, err := parseRepo(repo) if err != nil { @@ -421,17 +455,33 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete milestone %q?", ms.Title)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting milestone...") _, err = client.DeleteMilestone(owner, name, ms.ID) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete milestone: %w", err) } - fmt.Printf("Milestone deleted: %s\n", ms.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone deleted: %s\n", cs.SuccessIcon(), ms.Title) return nil } diff --git a/cmd/pr.go b/cmd/pr.go index a3a92d6..72bbb9f 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -2,14 +2,15 @@ package cmd import ( "fmt" - "os" - "strconv" + "net/http" + "os/exec" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + gitpkg "forgejo.zerova.net/sid/fgj-sid/internal/git" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -24,30 +25,120 @@ var prListCmd = &cobra.Command{ Use: "list [flags]", Short: "List pull requests", Long: "List pull requests in a repository.", - RunE: runPRList, + Example: ` # List open pull requests + fgj pr list + + # List all pull requests for a specific repo + fgj pr list -s all -R owner/repo + + # Output as JSON + fgj pr list --json`, + RunE: runPRList, } var prViewCmd = &cobra.Command{ - Use: "view ", + Use: "view []", Short: "View a pull request", Long: "Display detailed information about a pull request.", - Args: cobra.ExactArgs(1), - RunE: runPRView, + Example: ` # View pull request #5 + fgj pr view 5 + + # View using URL + fgj pr view https://codeberg.org/owner/repo/pulls/5 + + # View PR for current branch + fgj pr view + + # Open in browser + fgj pr view 5 --web + + # View as JSON + fgj pr view 5 --json`, + Args: cobra.MaximumNArgs(1), + RunE: runPRView, } var prCreateCmd = &cobra.Command{ Use: "create", Short: "Create a pull request", Long: "Create a new pull request.", - RunE: runPRCreate, + Example: ` # Create a pull request from feature branch to main + fgj pr create -t "Add login page" -H feature/login + + # Create with body and custom base branch + fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop + + # Create and self-assign + fgj pr create -t "Update docs" -H docs/update -a @me`, + RunE: runPRCreate, } var prMergeCmd = &cobra.Command{ Use: "merge ", Short: "Merge a pull request", Long: "Merge a pull request.", - Args: cobra.ExactArgs(1), - RunE: runPRMerge, + Example: ` # Merge pull request #5 + fgj pr merge 5 + + # Squash merge + fgj pr merge 5 --merge-method squash + + # Rebase merge + fgj pr merge 5 --merge-method rebase + + # Merge without confirmation + fgj pr merge 5 -y`, + Args: cobra.ExactArgs(1), + RunE: runPRMerge, +} + +var prCloseCmd = &cobra.Command{ + Use: "close ", + Short: "Close a pull request", + Long: "Close a pull request without merging.", + Example: ` # Close PR #5 + fgj pr close 5 + + # Close with a comment + fgj pr close 5 -c "Won't merge, superseded by #10"`, + Args: cobra.ExactArgs(1), + RunE: runPRClose, +} + +var prReopenCmd = &cobra.Command{ + Use: "reopen ", + Short: "Reopen a pull request", + Long: "Reopen a closed pull request.", + Example: ` # Reopen PR #5 + fgj pr reopen 5`, + Args: cobra.ExactArgs(1), + RunE: runPRReopen, +} + +var prEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a pull request", + Long: "Edit a pull request's title, body, or metadata.", + Example: ` # Update the title of PR #5 + fgj pr edit 5 -t "Updated title" + + # Add assignees and labels + fgj pr edit 5 --add-assignee user1 --add-label bug + + # Remove a reviewer and set milestone + fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`, + Args: cobra.ExactArgs(1), + RunE: runPREdit, +} + +var prCheckoutCmd = &cobra.Command{ + Use: "checkout ", + Short: "Check out a pull request locally", + Long: "Check out the head branch of a pull request.", + Example: ` # Check out PR #5 + fgj pr checkout 5`, + Args: cobra.ExactArgs(1), + RunE: runPRCheckout, } func init() { @@ -56,13 +147,32 @@ func init() { prCmd.AddCommand(prViewCmd) prCmd.AddCommand(prCreateCmd) prCmd.AddCommand(prMergeCmd) + prCmd.AddCommand(prCloseCmd) + prCmd.AddCommand(prReopenCmd) + prCmd.AddCommand(prEditCmd) + prCmd.AddCommand(prCheckoutCmd) + + prCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") + + prReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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") + prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") + prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username") + prListCmd.Flags().String("author", "", "Filter by author username") + prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") + prListCmd.Flags().StringP("search", "S", "", "Search keyword filter") + prListCmd.Flags().Bool("draft", false, "Filter by draft status") + prListCmd.Flags().String("head", "", "Filter by head branch") + prListCmd.Flags().String("base", "", "Filter by base branch") + prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser") + addJSONFlags(prListCmd, "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") + addJSONFlags(prViewCmd, "Output pull request as JSON") + prViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") @@ -70,14 +180,43 @@ func init() { prCreateCmd.Flags().StringP("head", "H", "", "Head branch") prCreateCmd.Flags().StringP("base", "B", "", "Base branch (default: main)") prCreateCmd.Flags().StringSliceP("assignee", "a", []string{}, "Assign people by their login. Use \"@me\" to self-assign.") + prCreateCmd.Flags().BoolP("draft", "d", false, "Create as draft pull request") + prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviewers by username") + prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by name") + prCreateCmd.Flags().StringP("milestone", "m", "", "Set milestone by name") prMergeCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prMergeCmd.Flags().String("merge-method", "merge", "Merge method: merge, rebase, squash") + prMergeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + prMergeCmd.Flags().BoolP("delete-branch", "d", false, "Delete the branch after merge") + prMergeCmd.Flags().Bool("auto", false, "Merge automatically when checks succeed") + + prCheckoutCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + prEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prEditCmd.Flags().StringP("title", "t", "", "New title for the pull request") + prEditCmd.Flags().StringP("body", "b", "", "New body for the pull request") + prEditCmd.Flags().StringP("base", "B", "", "New base branch for the pull request") + prEditCmd.Flags().StringSlice("add-assignee", nil, "Assignees to add (login names)") + prEditCmd.Flags().StringSlice("remove-assignee", nil, "Assignees to remove (login names)") + prEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (by name)") + prEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (by name)") + prEditCmd.Flags().StringSlice("add-reviewer", nil, "Reviewers to add (login names)") + prEditCmd.Flags().StringSlice("remove-reviewer", nil, "Reviewers to remove (login names)") + prEditCmd.Flags().String("milestone", "", "Milestone name to set (empty string to clear)") } func runPRList(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") state, _ := cmd.Flags().GetString("state") + limit, _ := cmd.Flags().GetInt("limit") + assignee, _ := cmd.Flags().GetString("assignee") + author, _ := cmd.Flags().GetString("author") + labels, _ := cmd.Flags().GetStringSlice("label") + search, _ := cmd.Flags().GetString("search") + draft, _ := cmd.Flags().GetBool("draft") + head, _ := cmd.Flags().GetString("head") + base, _ := cmd.Flags().GetString("base") owner, name, err := parseRepo(repo) if err != nil { @@ -94,6 +233,10 @@ func runPRList(cmd *cobra.Command, args []string) error { return err } + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(fmt.Sprintf("https://%s/%s/%s/pulls", client.Hostname(), owner, name)) + } + var stateType gitea.StateType switch strings.ToLower(state) { case "open": @@ -106,38 +249,113 @@ func runPRList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } - prs, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ - State: stateType, - }) - if err != nil { - return fmt.Errorf("failed to list pull requests: %w", err) - } + needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(prs) + ios.StartSpinner("Fetching pull requests...") + var prs []*gitea.PullRequest + if needsClientFilter { + page := 1 + for { + batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: stateType, + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to list pull requests: %w", err) + } + prs = append(prs, batch...) + if len(batch) < 50 { + break + } + page++ + } + prs = filterPRs(prs, author, assignee, labels, search, draft, head, base) + if len(prs) > limit { + prs = prs[:limit] + } + } else { + prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: stateType, + ListOptions: gitea.ListOptions{PageSize: limit}, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to list pull requests: %w", err) + } + } + ios.StopSpinner() + + if wantJSON(cmd) { + return outputJSON(cmd, prs) } if len(prs) == 0 { - fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "No %s pull requests in %s/%s\n", state, owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tBRANCH\tSTATE\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NUMBER", "TITLE", "BRANCH", "STATE") for _, pr := range prs { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", pr.Index, pr.Title, pr.Head.Ref, pr.State) + tp.AddRow(fmt.Sprintf("#%d", pr.Index), pr.Title, pr.Head.Ref, string(pr.State)) } - _ = w.Flush() + return tp.Render() +} - return nil +func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []string, search string, draft bool, head, base string) []*gitea.PullRequest { + var result []*gitea.PullRequest + for _, pr := range prs { + if author != "" && !strings.EqualFold(pr.Poster.UserName, author) { + continue + } + if assignee != "" { + found := false + for _, a := range pr.Assignees { + if strings.EqualFold(a.UserName, assignee) { + found = true + break + } + } + if !found { + continue + } + } + if len(labels) > 0 { + prLabelNames := make(map[string]bool) + for _, l := range pr.Labels { + prLabelNames[strings.ToLower(l.Name)] = true + } + allFound := true + for _, label := range labels { + if !prLabelNames[strings.ToLower(label)] { + allFound = false + break + } + } + if !allFound { + continue + } + } + if search != "" && !strings.Contains(strings.ToLower(pr.Title), strings.ToLower(search)) { + continue + } + if draft && !pr.Draft { + continue + } + if head != "" && !strings.EqualFold(pr.Head.Ref, head) { + continue + } + if base != "" && !strings.EqualFold(pr.Base.Ref, base) { + continue + } + result = append(result, pr) + } + return result } func runPRView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - prNumber, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid pull request number: %w", err) - } owner, name, err := parseRepo(repo) if err != nil { @@ -154,24 +372,77 @@ func runPRView(cmd *cobra.Command, args []string) error { return err } + var prNumber int64 + if len(args) == 0 { + // Try to find PR for current branch + branch, branchErr := gitpkg.GetCurrentBranch() + if branchErr != nil { + return fmt.Errorf("no pull request number specified and could not detect current branch: %w", branchErr) + } + + ios.StartSpinner("Finding pull request for branch...") + prs, _, listErr := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: gitea.StateOpen, + }) + ios.StopSpinner() + if listErr != nil { + return fmt.Errorf("failed to list pull requests: %w", listErr) + } + + var found bool + for _, pr := range prs { + if pr.Head.Ref == branch { + prNumber = pr.Index + found = true + break + } + } + if !found { + return fmt.Errorf("no open pull request found for branch %q", branch) + } + } else { + prNumber, err = parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + } + + ios.StartSpinner("Fetching pull request...") pr, _, err := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(pr) + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(pr.HTMLURL) } - fmt.Printf("Pull Request #%d\n", pr.Index) - fmt.Printf("Title: %s\n", pr.Title) - fmt.Printf("State: %s\n", pr.State) - fmt.Printf("Author: %s\n", pr.Poster.UserName) - fmt.Printf("Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) - fmt.Printf("Created: %s\n", pr.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", pr.Updated.Format("2006-01-02 15:04:05")) + if wantJSON(cmd) { + return outputJSON(cmd, pr) + } + + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "%s Pull Request #%d\n", cs.Bold(""), pr.Index) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(pr.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", pr.State) + fmt.Fprintf(ios.Out, "Author: %s\n", pr.Poster.UserName) + fmt.Fprintf(ios.Out, "Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) + if pr.Created != nil { + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(*pr.Created, isTTY)) + } + if pr.Updated != nil { + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*pr.Updated, isTTY)) + } if pr.Body != "" { - fmt.Printf("\n%s\n", pr.Body) + fmt.Fprintf(ios.Out, "\n%s\n", pr.Body) } return nil @@ -184,22 +455,44 @@ func runPRCreate(cmd *cobra.Command, args []string) error { head, _ := cmd.Flags().GetString("head") base, _ := cmd.Flags().GetString("base") assignees, _ := cmd.Flags().GetStringSlice("assignee") - - if base == "" { - base = "main" - } + draft, _ := cmd.Flags().GetBool("draft") + reviewers, _ := cmd.Flags().GetStringSlice("reviewer") + labelNames, _ := cmd.Flags().GetStringSlice("label") + milestoneName, _ := cmd.Flags().GetString("milestone") owner, name, err := parseRepo(repo) if err != nil { return err } - if title == "" { - return fmt.Errorf("title is required") + // Interactive mode: prompt for missing fields when TTY + if title == "" && ios.IsStdinTTY() { + title, err = promptLine("Title: ") + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("title is required") + } + } else if title == "" { + return fmt.Errorf("title is required (use -t flag)") } + if head == "" && ios.IsStdinTTY() { + // Default to current branch + branch, branchErr := gitpkg.GetCurrentBranch() + if branchErr == nil { + head = branch + fmt.Fprintf(ios.ErrOut, "Using current branch %q as head\n", head) + } else { + head, err = promptLine("Head branch: ") + if err != nil { + return err + } + } + } if head == "" { - return fmt.Errorf("head branch is required") + return fmt.Errorf("head branch is required (use -H flag)") } cfg, err := config.Load() @@ -212,6 +505,16 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } + if base == "" { + ios.StartSpinner("Fetching repository info...") + repoInfo, _, err := client.GetRepo(owner, name) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get repository info: %w", err) + } + base = repoInfo.DefaultBranch + } + // Resolve @me in assignees resolvedAssignees := make([]string, 0, len(assignees)) for _, assignee := range assignees { @@ -226,19 +529,62 @@ func runPRCreate(cmd *cobra.Command, args []string) error { } } + // Resolve label names to IDs + var labelIDs []int64 + if len(labelNames) > 0 { + labelIDs, err = resolveLabelIDs(client, owner, name, labelNames) + if err != nil { + return err + } + } + + // Resolve milestone name to ID + var milestoneID int64 + if milestoneName != "" { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + found := false + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + found = true + break + } + } + if !found { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + } + + ios.StartSpinner("Creating pull request...") pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{ Title: title, Body: body, Head: head, Base: base, Assignees: resolvedAssignees, + Reviewers: reviewers, + Labels: labelIDs, + Milestone: milestoneID, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create pull request: %w", err) } - fmt.Printf("Pull request created: #%d\n", pr.Index) - fmt.Printf("View at: %s\n", pr.HTMLURL) + // Set draft status via raw API if needed + if draft { + _, draftErr := client.DoJSON("PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, pr.Index), map[string]any{"draft": true}, nil) + if draftErr != nil { + return fmt.Errorf("failed to set pull request as draft: %w", draftErr) + } + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request created: #%d\n", cs.SuccessIcon(), pr.Index) + fmt.Fprintf(ios.Out, "View at: %s\n", pr.HTMLURL) return nil } @@ -246,7 +592,11 @@ func runPRCreate(cmd *cobra.Command, args []string) error { func runPRMerge(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") mergeMethod, _ := cmd.Flags().GetString("merge-method") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + yes, _ := cmd.Flags().GetBool("yes") + deleteBranch, _ := cmd.Flags().GetBool("delete-branch") + autoMerge, _ := cmd.Flags().GetBool("auto") + + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -278,14 +628,432 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid merge method: %s", mergeMethod) } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Merge pull request #%d via %s?", prNumber, mergeMethod)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Merging pull request...") _, _, err = client.MergePullRequest(owner, name, prNumber, gitea.MergePullRequestOption{ - Style: method, + Style: method, + DeleteBranchAfterMerge: deleteBranch, + MergeWhenChecksSucceed: autoMerge, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to merge pull request: %w", err) } - fmt.Printf("Pull request #%d merged successfully\n", prNumber) + cs := ios.ColorScheme() + if autoMerge { + fmt.Fprintf(ios.Out, "%s Auto-merge enabled for PR #%d\n", cs.SuccessIcon(), prNumber) + } else { + fmt.Fprintf(ios.Out, "%s Pull request #%d merged successfully\n", cs.SuccessIcon(), prNumber) + } + + return nil +} + +func runPRClose(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + commentBody, _ := cmd.Flags().GetString("comment") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + if commentBody != "" { + ios.StartSpinner("Adding comment...") + _, _, err = client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{ + Body: commentBody, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + } + + ios.StartSpinner("Closing pull request...") + stateClosed := gitea.StateClosed + _, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{ + State: &stateClosed, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to close pull request: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d closed\n", cs.SuccessIcon(), prNumber) + + return nil +} + +func runPRReopen(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Reopening pull request...") + stateOpen := gitea.StateOpen + _, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{ + State: &stateOpen, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to reopen pull request: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d reopened\n", cs.SuccessIcon(), prNumber) + + return nil +} + +func runPRCheckout(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching pull request...") + pr, _, err := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get pull request: %w", err) + } + + headBranch := pr.Head.Ref + headRepo := pr.Head.Repository + + // Determine if same-repo or cross-repo PR + isSameRepo := headRepo == nil || headRepo.FullName == fmt.Sprintf("%s/%s", owner, name) + + if isSameRepo { + // Same repo: fetch and checkout + ios.StartSpinner("Checking out branch...") + + gitFetch := exec.Command("git", "fetch", "origin", headBranch) + gitFetch.Stdout = ios.Out + gitFetch.Stderr = ios.ErrOut + if err := gitFetch.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to fetch branch: %w", err) + } + + // Try to checkout existing branch first + gitCheckout := exec.Command("git", "checkout", headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + // Branch doesn't exist locally, create it tracking remote + gitCheckout = exec.Command("git", "checkout", "-b", headBranch, "origin/"+headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to checkout branch: %w", err) + } + } else { + // Branch existed, pull latest + gitPull := exec.Command("git", "pull") + gitPull.Stdout = ios.Out + gitPull.Stderr = ios.ErrOut + _ = gitPull.Run() + } + ios.StopSpinner() + } else { + // Cross-repo (fork): add remote and checkout + forkOwner := headRepo.Owner.UserName + forkCloneURL := headRepo.CloneURL + + ios.StartSpinner("Checking out branch from fork...") + + // Add fork as remote (ignore error if already exists) + gitRemoteAdd := exec.Command("git", "remote", "add", forkOwner, forkCloneURL) + gitRemoteAdd.Stdout = ios.Out + gitRemoteAdd.Stderr = ios.ErrOut + _ = gitRemoteAdd.Run() // ignore error if remote already exists + + // Fetch from fork + gitFetch := exec.Command("git", "fetch", forkOwner) + gitFetch.Stdout = ios.Out + gitFetch.Stderr = ios.ErrOut + if err := gitFetch.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to fetch from fork: %w", err) + } + + // Checkout the branch + gitCheckout := exec.Command("git", "checkout", "-b", headBranch, forkOwner+"/"+headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to checkout branch: %w", err) + } + ios.StopSpinner() + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Checked out PR #%d on branch %q\n", cs.SuccessIcon(), prNumber, headBranch) + return nil +} + +func runPREdit(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + // Check that at least one flag was provided + anyChanged := false + for _, flag := range []string{"title", "body", "base", "add-assignee", "remove-assignee", + "add-label", "remove-label", "add-reviewer", "remove-reviewer", "milestone"} { + if cmd.Flags().Changed(flag) { + anyChanged = true + break + } + } + if !anyChanged { + return fmt.Errorf("at least one of --title, --body, --base, --add-assignee, --remove-assignee, " + + "--add-label, --remove-label, --add-reviewer, --remove-reviewer, or --milestone must be provided") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + // Build EditPullRequestOption from changed flags + editOpt := gitea.EditPullRequestOption{} + needsEditCall := false + + if cmd.Flags().Changed("title") { + title, _ := cmd.Flags().GetString("title") + editOpt.Title = title + needsEditCall = true + } + + if cmd.Flags().Changed("body") { + body, _ := cmd.Flags().GetString("body") + editOpt.Body = &body + needsEditCall = true + } + + if cmd.Flags().Changed("base") { + base, _ := cmd.Flags().GetString("base") + editOpt.Base = base + needsEditCall = true + } + + // Handle milestone + if cmd.Flags().Changed("milestone") { + milestoneName, _ := cmd.Flags().GetString("milestone") + if milestoneName == "" { + // Clear milestone by setting to 0 + editOpt.Milestone = 0 + } else { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + var milestoneID int64 + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + break + } + } + if milestoneID == 0 { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + editOpt.Milestone = milestoneID + } + needsEditCall = true + } + + // Handle assignees (add/remove requires fetching current PR) + addAssignees, _ := cmd.Flags().GetStringSlice("add-assignee") + removeAssignees, _ := cmd.Flags().GetStringSlice("remove-assignee") + + if len(addAssignees) > 0 || len(removeAssignees) > 0 { + ios.StartSpinner("Fetching pull request...") + pr, _, prErr := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() + if prErr != nil { + return fmt.Errorf("failed to get pull request: %w", prErr) + } + + // Build current assignee set + assigneeSet := make(map[string]bool) + for _, a := range pr.Assignees { + assigneeSet[a.UserName] = true + } + + // Add new assignees + for _, a := range addAssignees { + assigneeSet[a] = true + } + + // Remove assignees + for _, a := range removeAssignees { + delete(assigneeSet, a) + } + + // Convert back to slice + newAssignees := make([]string, 0, len(assigneeSet)) + for a := range assigneeSet { + newAssignees = append(newAssignees, a) + } + + editOpt.Assignees = newAssignees + needsEditCall = true + } + + ios.StartSpinner("Updating pull request...") + + // Perform the edit API call if needed + if needsEditCall { + _, _, err = client.EditPullRequest(owner, name, prNumber, editOpt) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to edit pull request: %w", err) + } + } + + // Handle labels + addLabelNames, _ := cmd.Flags().GetStringSlice("add-label") + removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label") + + if len(addLabelNames) > 0 { + labelIDs, labelErr := resolveLabelIDs(client, owner, name, addLabelNames) + if labelErr != nil { + ios.StopSpinner() + return labelErr + } + _, _, err = client.AddIssueLabels(owner, name, prNumber, gitea.IssueLabelsOption{ + Labels: labelIDs, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to add labels: %w", err) + } + } + + if len(removeLabelNames) > 0 { + labelIDs, labelErr := resolveLabelIDs(client, owner, name, removeLabelNames) + if labelErr != nil { + ios.StopSpinner() + return labelErr + } + for _, labelID := range labelIDs { + _, err = client.DeleteIssueLabel(owner, name, prNumber, labelID) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to remove label %d: %w", labelID, err) + } + } + } + + // Handle reviewers + addReviewers, _ := cmd.Flags().GetStringSlice("add-reviewer") + removeReviewers, _ := cmd.Flags().GetStringSlice("remove-reviewer") + + if len(addReviewers) > 0 { + reviewerReq := map[string][]string{ + "reviewers": addReviewers, + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber) + _, err := client.DoJSON(http.MethodPost, endpoint, reviewerReq, nil) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to add reviewers: %w", err) + } + } + + if len(removeReviewers) > 0 { + reviewerReq := map[string][]string{ + "reviewers": removeReviewers, + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber) + _, err := client.DoJSON(http.MethodDelete, endpoint, reviewerReq, nil) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to remove reviewers: %w", err) + } + } + + ios.StopSpinner() + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d updated\n", cs.SuccessIcon(), prNumber) return nil } diff --git a/cmd/pr_checks.go b/cmd/pr_checks.go new file mode 100644 index 0000000..4198668 --- /dev/null +++ b/cmd/pr_checks.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/sid/fgj-sid/internal/api" + "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" + "github.com/spf13/cobra" +) + +var prChecksCmd = &cobra.Command{ + Use: "checks ", + Short: "Show CI status checks for a pull request", + Long: "Show the status of CI checks for a pull request.", + Example: ` # Show checks for PR #5 + fgj pr checks 5 + + # Output as JSON + fgj pr checks 5 --json`, + Args: cobra.ExactArgs(1), + RunE: runPRChecks, +} + +func init() { + prCmd.AddCommand(prChecksCmd) + prChecksCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + addJSONFlags(prChecksCmd, "Output checks as JSON") +} + +func runPRChecks(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching pull request...") + pr, _, err := client.GetPullRequest(owner, name, prNumber) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to get pull request: %w", err) + } + + statuses, _, err := client.ListStatuses(owner, name, pr.Head.Sha, gitea.ListStatusesOption{}) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get commit statuses: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, statuses) + } + + if len(statuses) == 0 { + fmt.Fprintf(ios.Out, "No status checks found for PR #%d\n", prNumber) + return nil + } + + cs := ios.ColorScheme() + tp := ios.NewTablePrinter() + tp.AddHeader("STATUS", "CONTEXT", "DESCRIPTION") + for _, s := range statuses { + status := formatCheckStatus(s.State, cs) + tp.AddRow(status, s.Context, s.Description) + } + return tp.Render() +} + +func formatCheckStatus(state gitea.StatusState, cs *iostreams.ColorScheme) string { + switch state { + case gitea.StatusSuccess: + return cs.Green("pass") + case gitea.StatusFailure, gitea.StatusError: + return cs.Red("fail") + case gitea.StatusPending: + return cs.Yellow("pending") + case gitea.StatusWarning: + return cs.Yellow("warn") + default: + return string(state) + } +} diff --git a/cmd/pr_diff.go b/cmd/pr_diff.go index f11ec89..cee3fce 100644 --- a/cmd/pr_diff.go +++ b/cmd/pr_diff.go @@ -2,14 +2,11 @@ package cmd import ( "fmt" - "os" - "strconv" "strings" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" "github.com/spf13/cobra" - "golang.org/x/term" ) var prDiffCmd = &cobra.Command{ @@ -46,7 +43,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error { nameOnly, _ := cmd.Flags().GetBool("name-only") stat, _ := cmd.Flags().GetBool("stat") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -69,7 +66,9 @@ func runPRDiff(cmd *cobra.Command, args []string) error { diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff", client.Hostname(), owner, name, prNumber) + ios.StartSpinner("Fetching diff...") diff, err := client.GetRawLog(diffURL) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get pull request diff: %w", err) } @@ -82,12 +81,18 @@ func runPRDiff(cmd *cobra.Command, args []string) error { return printDiffStat(diff) } + // Start pager for diffs + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + useColor := shouldColorize(colorMode) if useColor { return printColorizedDiff(diff) } - fmt.Print(diff) + fmt.Fprint(ios.Out, diff) return nil } @@ -99,7 +104,7 @@ func shouldColorize(mode string) bool { case "never": return false default: // "auto" - return term.IsTerminal(int(os.Stdout.Fd())) + return ios.ColorEnabled() } } @@ -111,7 +116,7 @@ func printNameOnly(diff string) error { name := strings.TrimPrefix(line, "+++ b/") if name != "" && !seen[name] { seen[name] = true - fmt.Println(name) + fmt.Fprintln(ios.Out, name) } } } @@ -120,9 +125,9 @@ func printNameOnly(diff string) error { // fileStat holds per-file diff statistics. type fileStat struct { - name string - additions int - deletions int + name string + additions int + deletions int } // printDiffStat parses the diff and prints a diffstat summary. @@ -165,10 +170,12 @@ func printDiffStat(diff string) error { } if len(stats) == 0 { - fmt.Println("0 files changed") + fmt.Fprintln(ios.Out, "0 files changed") return nil } + cs := ios.ColorScheme() + // Find the longest file name for alignment maxNameLen := 0 maxChanges := 0 @@ -210,44 +217,36 @@ func printDiffStat(diff string) error { scaledDel = 1 } } - bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel) + bar = cs.Green(strings.Repeat("+", scaledAdd)) + cs.Red(strings.Repeat("-", scaledDel)) } - fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar) + fmt.Fprintf(ios.Out, " %-*s | %4d %s\n", maxNameLen, s.name, total, bar) } - fmt.Printf(" %d file", len(stats)) + fmt.Fprintf(ios.Out, " %d file", len(stats)) if len(stats) != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Printf(" changed, %d insertion", totalAdditions) + fmt.Fprintf(ios.Out, " changed, %d insertion", totalAdditions) if totalAdditions != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Printf("(+), %d deletion", totalDeletions) + fmt.Fprintf(ios.Out, "(+), %d deletion", totalDeletions) if totalDeletions != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Println("(-)") + fmt.Fprintln(ios.Out, "(-)") return nil } -// ANSI color codes for diff output. -const ( - colorReset = "\033[0m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorCyan = "\033[36m" - colorBold = "\033[1m" -) - -// printColorizedDiff prints the diff with ANSI color codes. +// printColorizedDiff prints the diff with ANSI color codes using ColorScheme. func printColorizedDiff(diff string) error { + cs := ios.ColorScheme() for _, line := range strings.Split(diff, "\n") { switch { case strings.HasPrefix(line, "diff --git "): - fmt.Println(colorBold + line + colorReset) + fmt.Fprintln(ios.Out, cs.Bold(line)) case strings.HasPrefix(line, "index "), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "), @@ -256,15 +255,15 @@ func printColorizedDiff(diff string) error { strings.HasPrefix(line, "similarity index"), strings.HasPrefix(line, "rename from"), strings.HasPrefix(line, "rename to"): - fmt.Println(colorBold + line + colorReset) + fmt.Fprintln(ios.Out, cs.Bold(line)) case strings.HasPrefix(line, "@@"): - fmt.Println(colorCyan + line + colorReset) + fmt.Fprintln(ios.Out, cs.Cyan(line)) case strings.HasPrefix(line, "+"): - fmt.Println(colorGreen + line + colorReset) + fmt.Fprintln(ios.Out, cs.Green(line)) case strings.HasPrefix(line, "-"): - fmt.Println(colorRed + line + colorReset) + fmt.Fprintln(ios.Out, cs.Red(line)) default: - fmt.Println(line) + fmt.Fprintln(ios.Out, line) } } return nil diff --git a/cmd/pr_review.go b/cmd/pr_review.go index d64095d..ea370bf 100644 --- a/cmd/pr_review.go +++ b/cmd/pr_review.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "strconv" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" @@ -57,7 +56,7 @@ func init() { prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCommentCmd.Flags().StringP("body", "b", "", "Comment body") prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") - prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON") + addJSONFlags(prCommentCmd, "Output created comment as JSON") prReviewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request") @@ -65,7 +64,7 @@ func init() { prReviewCmd.Flags().BoolP("comment", "c", false, "Submit as a review comment") prReviewCmd.Flags().StringP("body", "b", "", "Review body/message") prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") - prReviewCmd.Flags().Bool("json", false, "Output created review as JSON") + addJSONFlags(prReviewCmd, "Output created review as JSON") } // readBody resolves the body text from --body and --body-file flags. @@ -98,7 +97,7 @@ func readBody(cmd *cobra.Command) (string, error) { func runPRComment(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -127,19 +126,22 @@ func runPRComment(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Adding comment...") comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{ Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(comment) + if wantJSON(cmd) { + return outputJSON(cmd, comment) } - fmt.Printf("Comment added to PR #%d\n", prNumber) - fmt.Printf("View at: %s\n", comment.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Comment added to PR #%d\n", cs.SuccessIcon(), prNumber) + fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL) return nil } @@ -150,7 +152,7 @@ func runPRReview(cmd *cobra.Command, args []string) error { requestChanges, _ := cmd.Flags().GetBool("request-changes") commentReview, _ := cmd.Flags().GetBool("comment") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -208,21 +210,24 @@ func runPRReview(cmd *cobra.Command, args []string) error { action = "reviewed with comment" } + ios.StartSpinner("Submitting review...") review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{ State: state, Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create review: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(review) + if wantJSON(cmd) { + return outputJSON(cmd, review) } - fmt.Printf("PR #%d %s\n", prNumber, action) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, action) if review.HTMLURL != "" { - fmt.Printf("View at: %s\n", review.HTMLURL) + fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL) } return nil diff --git a/cmd/release.go b/cmd/release.go index cfc9d0a..7f3ffd4 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -3,14 +3,15 @@ package cmd import ( "fmt" "os" + "path" "path/filepath" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -25,39 +26,98 @@ var releaseListCmd = &cobra.Command{ Use: "list", Short: "List releases", Long: "List releases in a repository.", - RunE: runReleaseList, + Example: ` # List releases + fgj release list + + # List only draft releases + fgj release list --draft + + # Output as JSON with a custom limit + fgj release list --json --limit 10`, + RunE: runReleaseList, } var releaseViewCmd = &cobra.Command{ Use: "view ", Short: "View a release", Long: "Display detailed information about a release.", - Args: cobra.ExactArgs(1), - RunE: runReleaseView, + Example: ` # View a release by tag + fgj release view v1.0.0 + + # View the latest release + fgj release view latest + + # Open in browser + fgj release view v1.0.0 --web + + # Output as JSON + fgj release view v1.0.0 --json`, + Args: cobra.ExactArgs(1), + RunE: runReleaseView, } var releaseCreateCmd = &cobra.Command{ Use: "create [files...]", Short: "Create a release", Long: "Create a new release and optionally upload assets.", - Args: cobra.MinimumNArgs(1), - RunE: runReleaseCreate, + Example: ` # Create a release + fgj release create v1.0.0 + + # Create with title and notes + fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements" + + # Create a draft prerelease with assets + fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz + + # Create from release notes file + fgj release create v1.0.0 -F CHANGELOG.md`, + Args: cobra.MinimumNArgs(1), + RunE: runReleaseCreate, } var releaseUploadCmd = &cobra.Command{ Use: "upload ", Short: "Upload release assets", Long: "Upload assets to an existing release.", - Args: cobra.MinimumNArgs(2), - RunE: runReleaseUpload, + Example: ` # Upload assets to a release + fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64 + + # Upload to the latest release, overwriting existing assets + fgj release upload latest build/output.zip --clobber`, + Args: cobra.MinimumNArgs(2), + RunE: runReleaseUpload, +} + +var releaseDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download release assets", + Long: "Download assets from a release.", + Example: ` # Download all assets from a release + fgj release download v1.0.0 + + # Download to a specific directory + fgj release download v1.0.0 -D ./downloads + + # Download a specific asset by name pattern + fgj release download v1.0.0 -p "*.tar.gz"`, + Args: cobra.ExactArgs(1), + RunE: runReleaseDownload, } var releaseDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a release", Long: "Delete a release by tag, keeping its Git tag intact.", - Args: cobra.ExactArgs(1), - RunE: runReleaseDelete, + Example: ` # Delete a release by tag + fgj release delete v1.0.0 + + # Delete the latest release + fgj release delete latest + + # Delete without confirmation + fgj release delete v1.0.0 -y`, + Args: cobra.ExactArgs(1), + RunE: runReleaseDelete, } func init() { @@ -66,16 +126,18 @@ func init() { releaseCmd.AddCommand(releaseViewCmd) releaseCmd.AddCommand(releaseCreateCmd) releaseCmd.AddCommand(releaseUploadCmd) + releaseCmd.AddCommand(releaseDownloadCmd) releaseCmd.AddCommand(releaseDeleteCmd) releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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") + addJSONFlags(releaseListCmd, "Output releases as JSON") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - releaseViewCmd.Flags().Bool("json", false, "Output release as JSON") + addJSONFlags(releaseViewCmd, "Output release as JSON") + releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") @@ -88,7 +150,12 @@ func init() { releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name") + releaseDownloadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseDownloadCmd.Flags().StringP("dir", "D", ".", "Directory to download files into") + releaseDownloadCmd.Flags().StringP("pattern", "p", "", "Glob pattern to filter assets by name") + releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func runReleaseList(cmd *cobra.Command, args []string) error { @@ -131,11 +198,13 @@ func runReleaseList(cmd *cobra.Command, args []string) error { opts.IsPreRelease = &prereleaseValue } + ios.StartSpinner("Fetching releases...") var releases []*gitea.Release for page := 1; len(releases) < limit; page++ { opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize} batch, _, err := client.ListReleases(owner, name, opts) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to list releases: %w", err) } if len(batch) == 0 { @@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error { } releases = append(releases, batch...) } + ios.StopSpinner() if len(releases) > limit { releases = releases[:limit] } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(releases) + if wantJSON(cmd) { + return outputJSON(cmd, releases) } if len(releases) == 0 { - fmt.Printf("No releases in %s/%s\n", owner, name) + fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n") + isTTY := ios.IsStdoutTTY() + tp := ios.NewTablePrinter() + tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED") for _, rel := range releases { - published := releaseTimestamp(rel).Format("2006-01-02") - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published) + published := text.FormatDate(releaseTimestamp(rel), isTTY) + tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published) } - _ = w.Flush() - - return nil + return tp.Render() } func runReleaseView(cmd *cobra.Command, args []string) error { @@ -187,17 +256,27 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) if err != nil { + ios.StopSpinner() return err } attachments, err := listReleaseAttachments(client, owner, name, release.ID) + ios.StopSpinner() if err != nil { return err } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + if web, _ := cmd.Flags().GetBool("web"); web { + if release.HTMLURL != "" { + return ios.OpenInBrowser(release.HTMLURL) + } + return fmt.Errorf("release has no HTML URL") + } + + if wantJSON(cmd) { payload := struct { Release *gitea.Release `json:"release"` Assets []*gitea.Attachment `json:"assets,omitempty"` @@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error { Release: release, Assets: attachments, } - return writeJSON(payload) + return outputJSON(cmd, payload) } - fmt.Printf("Release %s\n", release.TagName) - fmt.Printf("Title: %s\n", release.Title) - fmt.Printf("Type: %s\n", releaseType(release)) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Release %s\n", cs.Bold(release.TagName)) + fmt.Fprintf(ios.Out, "Title: %s\n", release.Title) + fmt.Fprintf(ios.Out, "Type: %s\n", releaseType(release)) if release.Target != "" { - fmt.Printf("Target: %s\n", release.Target) + fmt.Fprintf(ios.Out, "Target: %s\n", release.Target) } if release.Publisher != nil { - fmt.Printf("Author: %s\n", release.Publisher.UserName) + fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName) } - fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY)) if !release.PublishedAt.IsZero() { - fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY)) } if release.HTMLURL != "" { - fmt.Printf("URL: %s\n", release.HTMLURL) + fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL) } if release.Note != "" { - fmt.Printf("\n%s\n", release.Note) + fmt.Fprintf(ios.Out, "\n%s\n", release.Note) } if len(attachments) > 0 { - fmt.Printf("\nAssets (%d):\n", len(attachments)) + fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments)) for _, asset := range attachments { - fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) + fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) } } @@ -281,6 +368,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Creating release...") release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ TagName: tag, Target: target, @@ -289,24 +377,29 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { IsDraft: draft, IsPrerelease: prerelease, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create release: %w", err) } - fmt.Printf("Release created: %s\n", release.TagName) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName) if release.HTMLURL != "" { - fmt.Printf("View at: %s\n", release.HTMLURL) + fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL) } if len(files) == 0 { return nil } + ios.StartSpinner("Uploading assets...") if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil { + ios.StopSpinner() return err } + ios.StopSpinner() - fmt.Printf("Uploaded %d asset(s)\n", len(files)) + fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } @@ -332,21 +425,29 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() if err != nil { return err } + ios.StartSpinner("Uploading assets...") if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil { + ios.StopSpinner() return err } + ios.StopSpinner() - fmt.Printf("Uploaded %d asset(s)\n", len(files)) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } -func runReleaseDelete(cmd *cobra.Command, args []string) error { +func runReleaseDownload(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") + dir, _ := cmd.Flags().GetString("dir") + pattern, _ := cmd.Flags().GetString("pattern") tag := args[0] owner, name, err := parseRepo(repo) @@ -364,16 +465,120 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + ios.StopSpinner() + return err + } + + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + ios.StopSpinner() if err != nil { return err } - if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { - return fmt.Errorf("failed to delete release: %w", err) + if len(attachments) == 0 { + fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName) + return nil } - fmt.Printf("Release %s deleted\n", release.TagName) + // Filter by pattern if provided + var toDownload []*gitea.Attachment + for _, a := range attachments { + if pattern != "" { + matched, matchErr := path.Match(pattern, a.Name) + if matchErr != nil { + return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr) + } + if !matched { + continue + } + } + toDownload = append(toDownload, a) + } + + if len(toDownload) == 0 { + fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName) + return nil + } + + // Ensure download directory exists + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + for _, a := range toDownload { + destPath := filepath.Join(dir, a.Name) + f, createErr := os.Create(destPath) + if createErr != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, createErr) + } + + dlErr := client.DownloadFile(a.DownloadURL, f) + closeErr := f.Close() + if dlErr != nil { + return fmt.Errorf("failed to download %s: %w", a.Name, dlErr) + } + if closeErr != nil { + return fmt.Errorf("failed to close %s: %w", destPath, closeErr) + } + + fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir) + return nil +} + +func runReleaseDelete(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") + tag := args[0] + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching release...") + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() + if err != nil { + return err + } + + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete release %s?", release.TagName)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting release...") + if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to delete release: %w", err) + } + ios.StopSpinner() + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Release %s deleted\n", cs.SuccessIcon(), release.TagName) return nil } diff --git a/cmd/repo.go b/cmd/repo.go index 7ca22cd..d216198 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -6,11 +6,11 @@ import ( "os/exec" "path/filepath" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -78,17 +78,34 @@ var repoEditCmd = &cobra.Command{ # Change default branch fgj repo edit --default-branch develop + # Rename a repository + fgj repo edit owner/repo --name new-name + # Edit current repo (auto-detected from git context) fgj repo edit --public`, Args: cobra.MaximumNArgs(1), RunE: runRepoEdit, } +var repoRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Rename a repository", + Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name `.", + Example: ` # Rename current repo + fgj repo rename new-name + + # Rename a specific repo + fgj repo rename new-name -R owner/old-name`, + Args: cobra.ExactArgs(1), + RunE: runRepoRename, +} + func init() { rootCmd.AddCommand(repoCmd) repoCmd.AddCommand(repoCloneCmd) repoCmd.AddCommand(repoCreateCmd) repoCmd.AddCommand(repoEditCmd) + repoCmd.AddCommand(repoRenameCmd) repoCmd.AddCommand(repoForkCmd) repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoViewCmd) @@ -104,16 +121,25 @@ func init() { repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)") repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private") + addJSONFlags(repoViewCmd, "Output repository as JSON") + repoViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") + + addJSONFlags(repoListCmd, "Output repositories as JSON") + repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + repoEditCmd.Flags().String("name", "", "Rename the repository") repoEditCmd.Flags().StringP("description", "d", "", "Repository description") repoEditCmd.Flags().String("homepage", "", "Repository home page URL") repoEditCmd.Flags().String("default-branch", "", "Default branch name") repoEditCmd.Flags().Bool("private", false, "Make the repository private") repoEditCmd.Flags().Bool("public", false, "Make the repository public") - repoEditCmd.Flags().Bool("json", false, "Output updated repository as JSON") + addJSONFlags(repoEditCmd, "Output updated repository as JSON") repoEditCmd.MarkFlagsMutuallyExclusive("public", "private") + + repoRenameCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + addJSONFlags(repoRenameCmd, "Output updated repository as JSON") } func runRepoView(cmd *cobra.Command, args []string) error { @@ -137,23 +163,36 @@ func runRepoView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repository...") repository, _, err := client.GetRepo(owner, name) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get repository: %w", err) } - fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name) - fmt.Printf("Description: %s\n", repository.Description) - fmt.Printf("URL: %s\n", repository.HTMLURL) - fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL) - fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL) - fmt.Printf("Default Branch: %s\n", repository.DefaultBranch) - fmt.Printf("Stars: %d\n", repository.Stars) - fmt.Printf("Forks: %d\n", repository.Forks) - fmt.Printf("Open Issues: %d\n", repository.OpenIssues) - fmt.Printf("Private: %v\n", repository.Private) - fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05")) + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(repository.HTMLURL) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Repository: %s\n", cs.Bold(fmt.Sprintf("%s/%s", repository.Owner.UserName, repository.Name))) + fmt.Fprintf(ios.Out, "Description: %s\n", repository.Description) + fmt.Fprintf(ios.Out, "URL: %s\n", repository.HTMLURL) + fmt.Fprintf(ios.Out, "Clone URL (HTTPS): %s\n", repository.CloneURL) + fmt.Fprintf(ios.Out, "Clone URL (SSH): %s\n", repository.SSHURL) + fmt.Fprintf(ios.Out, "Default Branch: %s\n", repository.DefaultBranch) + fmt.Fprintf(ios.Out, "Stars: %d\n", repository.Stars) + fmt.Fprintf(ios.Out, "Forks: %d\n", repository.Forks) + fmt.Fprintf(ios.Out, "Open Issues: %d\n", repository.OpenIssues) + fmt.Fprintf(ios.Out, "Private: %v\n", repository.Private) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(repository.Created, isTTY)) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(repository.Updated, isTTY)) return nil } @@ -169,37 +208,39 @@ func runRepoList(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repositories...") user, _, err := client.GetMyUserInfo() if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get user info: %w", err) } repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list repositories: %w", err) } + if wantJSON(cmd) { + return outputJSON(cmd, repos) + } + if len(repos) == 0 { - fmt.Println("No repositories found") + fmt.Fprintln(ios.Out, "No repositories found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION") for _, repo := range repos { visibility := "public" if repo.Private { visibility = "private" } - desc := repo.Description - if len(desc) > 50 { - desc = desc[:47] + "..." - } - _, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc) + desc := text.Truncate(repo.Description, 50) + tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc) } - _ = w.Flush() - - return nil + return tp.Render() } func runRepoClone(cmd *cobra.Command, args []string) error { @@ -221,7 +262,9 @@ func runRepoClone(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repository info...") repository, _, err := client.GetRepo(owner, name) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get repository: %w", err) } @@ -241,7 +284,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { destination = name } - fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination) + fmt.Fprintf(ios.Out, "Cloning %s/%s to %s...\n", owner, name, destination) // Create parent directory if it doesn't exist if dir := filepath.Dir(destination); dir != "." { @@ -250,17 +293,21 @@ func runRepoClone(cmd *cobra.Command, args []string) error { } } + ios.StartSpinner("Cloning repository...") // Execute git clone gitCmd := exec.Command("git", "clone", cloneURL, destination) - gitCmd.Stdout = os.Stdout - gitCmd.Stderr = os.Stderr - gitCmd.Stdin = os.Stdin + gitCmd.Stdout = ios.Out + gitCmd.Stderr = ios.ErrOut + gitCmd.Stdin = ios.In if err := gitCmd.Run(); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to clone repository: %w", err) } + ios.StopSpinner() - fmt.Printf("Repository cloned successfully to %s\n", destination) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository cloned successfully to %s\n", cs.SuccessIcon(), destination) return nil } @@ -282,14 +329,17 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Forking repository...") fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to fork repository: %w", err) } - fmt.Printf("Repository forked successfully\n") - fmt.Printf("View at: %s\n", fork.HTMLURL) - fmt.Printf("Clone URL: %s\n", fork.CloneURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon()) + fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL) + fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL) return nil } @@ -335,12 +385,14 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { License: license, } + ios.StartSpinner("Creating repository...") var repo *gitea.Repository if isOrg { repo, _, err = client.CreateOrgRepo(org, opt) } else { repo, _, err = client.CreateRepo(opt) } + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create repository: %w", err) } @@ -354,7 +406,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { } else { user, _, userErr := client.GetMyUserInfo() if userErr != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr) + fmt.Fprintf(ios.ErrOut, "warning: repository created but could not determine owner for homepage: %v\n", userErr) homepage = "" // skip EditRepo } else { ownerName = user.UserName @@ -366,23 +418,24 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { Website: &homepage, }) if err != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err) + fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to set homepage: %v\n", err) } } } if team != "" { if !isOrg { - fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories") + fmt.Fprintln(ios.ErrOut, "warning: --team is only meaningful for organization repositories") } else { _, err = client.AddRepoTeam(org, repo.Name, team) if err != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err) + fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to add team %q: %v\n", team, err) } } } - fmt.Printf("Repository created: %s\n", repo.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository created: %s\n", cs.SuccessIcon(), repo.HTMLURL) if doClone { cloneURL := repo.CloneURL @@ -391,11 +444,11 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { cloneURL = repo.SSHURL } } - fmt.Printf("Cloning into %s...\n", repo.Name) + fmt.Fprintf(ios.Out, "Cloning into %s...\n", repo.Name) gitCmd := exec.Command("git", "clone", cloneURL, repo.Name) - gitCmd.Stdout = os.Stdout - gitCmd.Stderr = os.Stderr - gitCmd.Stdin = os.Stdin + gitCmd.Stdout = ios.Out + gitCmd.Stderr = ios.ErrOut + gitCmd.Stdin = ios.In if err := gitCmd.Run(); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } @@ -449,6 +502,11 @@ func runRepoEdit(cmd *cobra.Command, args []string) error { opt := gitea.EditRepoOption{} changed := false + if cmd.Flags().Changed("name") { + n, _ := cmd.Flags().GetString("name") + opt.Name = &n + changed = true + } if cmd.Flags().Changed("description") { d, _ := cmd.Flags().GetString("description") opt.Description = &d @@ -476,36 +534,84 @@ func runRepoEdit(cmd *cobra.Command, args []string) error { } if !changed { - return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch") + return fmt.Errorf("no changes specified; use flags like --name, --public, --private, --description, --homepage, or --default-branch") } + ios.StartSpinner("Updating repository...") repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit repository: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(repository) + if wantJSON(cmd) { + return outputJSON(cmd, repository) } - fmt.Printf("Repository updated: %s\n", repository.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository updated: %s\n", cs.SuccessIcon(), repository.HTMLURL) + if opt.Name != nil { + fmt.Fprintf(ios.Out, "Renamed to: %s\n", repository.FullName) + } if opt.Private != nil { if *opt.Private { - fmt.Println("Visibility: private") + fmt.Fprintln(ios.Out, "Visibility: private") } else { - fmt.Println("Visibility: public") + fmt.Fprintln(ios.Out, "Visibility: public") } } if opt.Description != nil { - fmt.Printf("Description: %s\n", *opt.Description) + fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description) } if opt.Website != nil { - fmt.Printf("Homepage: %s\n", *opt.Website) + fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website) } if opt.DefaultBranch != nil { - fmt.Printf("Default branch: %s\n", *opt.DefaultBranch) + fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch) } return nil } + +func runRepoRename(cmd *cobra.Command, args []string) error { + var repo string + if r, _ := cmd.Flags().GetString("repo"); r != "" { + repo = r + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + newName := args[0] + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + opt := gitea.EditRepoOption{ + Name: &newName, + } + + ios.StartSpinner("Renaming repository...") + repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to rename repository: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Renamed %s/%s to %s\n", cs.SuccessIcon(), owner, name, repository.FullName) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index dab7fb4..edb1c39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "strings" "forgejo.zerova.net/sid/fgj-sid/internal/git" @@ -46,7 +47,7 @@ func initConfig() { } else { home, err := os.UserHomeDir() if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(ios.ErrOut, err) os.Exit(1) } @@ -94,3 +95,26 @@ func getDetectedHost() string { } return host } + +// promptLine prints a prompt to stderr and reads a line from stdin. +func promptLine(prompt string) (string, error) { + fmt.Fprint(ios.ErrOut, prompt) + var buf [1024]byte + n, err := ios.In.Read(buf[:]) + if err != nil { + return "", fmt.Errorf("reading input: %w", err) + } + return strings.TrimSpace(string(buf[:n])), nil +} + +// parseIssueArg parses an issue/PR number from various formats: +// "123", "#123", "https://host/owner/repo/pulls/123", "https://host/owner/repo/issues/123" +func parseIssueArg(arg string) (int64, error) { + arg = strings.TrimPrefix(arg, "#") + // Try URL format + if strings.HasPrefix(arg, "http") { + parts := strings.Split(strings.TrimRight(arg, "/"), "/") + arg = parts[len(parts)-1] + } + return strconv.ParseInt(arg, 10, 64) +} diff --git a/cmd/wiki.go b/cmd/wiki.go index 1e5786a..4c2d65d 100644 --- a/cmd/wiki.go +++ b/cmd/wiki.go @@ -5,29 +5,28 @@ import ( "fmt" "net/http" "net/url" - "os" - "text/tabwriter" "time" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) // Wiki API response types type wikiPageMeta struct { - Title string `json:"title"` - HTMLURL string `json:"html_url"` - SubURL string `json:"sub_url"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + SubURL string `json:"sub_url"` LastCommit *wikiCommit `json:"last_commit"` } type wikiCommit struct { - ID string `json:"id"` - Author *wikiUser `json:"author"` - Committer *wikiUser `json:"committer"` - Message string `json:"message"` + ID string `json:"id"` + Author *wikiUser `json:"author"` + Committer *wikiUser `json:"committer"` + Message string `json:"message"` } type wikiUser struct { @@ -79,6 +78,9 @@ var wikiViewCmd = &cobra.Command{ Example: ` # View a wiki page fgj wiki view Home + # Open in browser + fgj wiki view Home --web + # View a wiki page as JSON (includes content) fgj wiki view Home --json @@ -133,6 +135,9 @@ var wikiDeleteCmd = &cobra.Command{ Example: ` # Delete a wiki page fgj wiki delete "Old Page" + # Delete without confirmation + fgj wiki delete "Old Page" -y + # Delete a wiki page from a specific repo fgj wiki delete "Outdated Guide" -R owner/repo`, Args: cobra.ExactArgs(1), @@ -148,22 +153,24 @@ func init() { wikiCmd.AddCommand(wikiDeleteCmd) wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - wikiListCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(wikiListCmd, "Output as JSON") wikiViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - wikiViewCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(wikiViewCmd, "Output as JSON") + wikiViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") wikiCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiCreateCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") - wikiCreateCmd.Flags().Bool("json", false, "Output created page as JSON") + addJSONFlags(wikiCreateCmd, "Output created page as JSON") wikiEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiEditCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") - wikiEditCmd.Flags().Bool("json", false, "Output updated page as JSON") + addJSONFlags(wikiEditCmd, "Output updated page as JSON") wikiDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func newWikiClient(cmd *cobra.Command) (*api.Client, string, string, error) { @@ -194,37 +201,38 @@ func runWikiList(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(name)) + ios.StartSpinner("Fetching wiki pages...") var pages []wikiPageMeta if err := client.GetJSON(path, &pages); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to list wiki pages: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(pages) + if wantJSON(cmd) { + return outputJSON(cmd, pages) } if len(pages) == 0 { - fmt.Println("No wiki pages found") + fmt.Fprintln(ios.Out, "No wiki pages found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "TITLE\tLAST UPDATED\n") + isTTY := ios.IsStdoutTTY() + tp := ios.NewTablePrinter() + tp.AddHeader("TITLE", "LAST UPDATED") for _, p := range pages { updated := "" if p.LastCommit != nil && p.LastCommit.Committer != nil && p.LastCommit.Committer.Date != "" { if t, err := time.Parse(time.RFC3339, p.LastCommit.Committer.Date); err == nil { - updated = t.Format("2006-01-02 15:04:05") + updated = text.FormatDate(t, isTTY) } else { updated = p.LastCommit.Committer.Date } } - _, _ = fmt.Fprintf(w, "%s\t%s\n", p.Title, updated) + tp.AddRow(p.Title, updated) } - _ = w.Flush() - - return nil + return tp.Render() } func runWikiView(cmd *cobra.Command, args []string) error { @@ -238,27 +246,42 @@ func runWikiView(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + ios.StartSpinner("Fetching wiki page...") var page wikiPage if err := client.GetJSON(path, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get wiki page: %w", err) } + ios.StopSpinner() content, err := base64.StdEncoding.DecodeString(page.ContentBase64) if err != nil { return fmt.Errorf("failed to decode wiki page content: %w", err) } + if web, _ := cmd.Flags().GetBool("web"); web { + if page.HTMLURL != "" { + return ios.OpenInBrowser(page.HTMLURL) + } + return fmt.Errorf("wiki page has no HTML URL") + } + jsonFlag, _ := cmd.Flags().GetBool("json") if jsonFlag { page.Content = string(content) return writeJSON(page) } - fmt.Printf("# %s\n\n", page.Title) - fmt.Print(string(content)) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + fmt.Fprintf(ios.Out, "# %s\n\n", page.Title) + fmt.Fprint(ios.Out, string(content)) // Ensure trailing newline if len(content) > 0 && content[len(content)-1] != '\n' { - fmt.Println() + fmt.Fprintln(ios.Out) } return nil @@ -288,17 +311,20 @@ func runWikiCreate(cmd *cobra.Command, args []string) error { ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), } + ios.StartSpinner("Creating wiki page...") var page wikiPage if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to create wiki page: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(page) + if wantJSON(cmd) { + return outputJSON(cmd, page) } - fmt.Printf("Wiki page created: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page created: %s\n", cs.SuccessIcon(), title) return nil } @@ -326,35 +352,54 @@ func runWikiEdit(cmd *cobra.Command, args []string) error { ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), } + ios.StartSpinner("Updating wiki page...") var page wikiPage if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to update wiki page: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(page) + if wantJSON(cmd) { + return outputJSON(cmd, page) } - fmt.Printf("Wiki page updated: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page updated: %s\n", cs.SuccessIcon(), title) return nil } func runWikiDelete(cmd *cobra.Command, args []string) error { title := args[0] + yes, _ := cmd.Flags().GetBool("yes") client, owner, name, err := newWikiClient(cmd) if err != nil { return err } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete wiki page %q?", title)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + ios.StartSpinner("Deleting wiki page...") if _, err := client.DoJSON(http.MethodDelete, path, nil, nil); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to delete wiki page: %w", err) } + ios.StopSpinner() - fmt.Printf("Wiki page deleted: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page deleted: %s\n", cs.SuccessIcon(), title) return nil } diff --git a/go.mod b/go.mod index 0025ca9..b13d8ce 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module forgejo.zerova.net/sid/fgj-sid -go 1.23.0 +go 1.24.0 require ( code.gitea.io/sdk/gitea v0.22.1 @@ -19,6 +19,8 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.18 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect 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 @@ -34,7 +36,7 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.26.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index f344c22..7618c0c 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -88,6 +92,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= diff --git a/internal/api/client.go b/internal/api/client.go index b69f3f7..d5869d3 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -6,11 +6,16 @@ import ( "fmt" "io" "net/http" + "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/config" ) +var sharedHTTPClient = &http.Client{ + Timeout: 30 * time.Second, +} + type Client struct { *gitea.Client hostname string @@ -63,8 +68,7 @@ func (c *Client) GetJSON(path string, result any) error { } req.Header.Set("Accept", "application/json") - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return fmt.Errorf("failed to perform request: %w", err) } @@ -74,8 +78,11 @@ func (c *Client) GetJSON(path string, result any) error { } }() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("failed to read error response body: %w", readErr) + } return &APIError{ StatusCode: resp.StatusCode, Body: string(body), @@ -125,8 +132,7 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, req.Header.Set("Content-Type", "application/json") } - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return 0, fmt.Errorf("failed to perform request: %w", err) } @@ -136,8 +142,11 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, } }() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { - bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return resp.StatusCode, fmt.Errorf("failed to read error response body: %w", readErr) + } return resp.StatusCode, &APIError{ StatusCode: resp.StatusCode, Body: string(bodyBytes), @@ -154,6 +163,40 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, return resp.StatusCode, nil } +// Token returns the client's authentication token. +func (c *Client) Token() string { + return c.token +} + +// DownloadFile performs an authenticated GET request and writes the response body to the given writer. +func (c *Client) DownloadFile(url string, w io.Writer) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := sharedHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body)) + } + + if _, err := io.Copy(w, resp.Body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + // GetRawLog performs a GET request and returns the raw response body as string func (c *Client) GetRawLog(url string) (string, error) { req, err := http.NewRequest(http.MethodGet, url, nil) @@ -166,8 +209,7 @@ func (c *Client) GetRawLog(url string) (string, error) { req.Header.Set("Authorization", "token "+c.token) } - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("failed to perform request: %w", err) } @@ -178,7 +220,10 @@ func (c *Client) GetRawLog(url string) (string, error) { }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", fmt.Errorf("failed to read error response body: %w", readErr) + } return "", &APIError{ StatusCode: resp.StatusCode, Body: string(body), diff --git a/internal/git/git.go b/internal/git/git.go index 425c764..0491953 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -114,6 +114,48 @@ func parseGitConfig(configPath string) (string, error) { return "", fmt.Errorf("no origin remote found in git config") } +// GetCurrentBranch returns the name of the currently checked-out branch. +func GetCurrentBranch() (string, error) { + gitDir, err := findGitDir() + if err != nil { + return "", err + } + + headPath := filepath.Join(gitDir, "HEAD") + data, err := os.ReadFile(headPath) + if err != nil { + return "", fmt.Errorf("failed to read .git/HEAD: %w", err) + } + + headStr := strings.TrimSpace(string(data)) + if strings.HasPrefix(headStr, "ref: refs/heads/") { + return strings.TrimPrefix(headStr, "ref: refs/heads/"), nil + } + + return "", fmt.Errorf("HEAD is not on a branch (detached HEAD)") +} + +// findGitDir searches for the .git directory starting from the current directory +func findGitDir() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := cwd + for { + gitDir := filepath.Join(dir, ".git") + if info, err := os.Stat(gitDir); err == nil && info.IsDir() { + return gitDir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("not in a git repository") + } + dir = parent + } +} + // parseRemoteURL extracts owner/name/hostname from various git URL formats: // - https://codeberg.org/owner/name.git // - git@codeberg.org:owner/name.git diff --git a/internal/iostreams/color.go b/internal/iostreams/color.go new file mode 100644 index 0000000..43a5475 --- /dev/null +++ b/internal/iostreams/color.go @@ -0,0 +1,77 @@ +package iostreams + +import "fmt" + +// ColorScheme provides semantic color methods that respect whether color output is enabled. +type ColorScheme struct { + enabled bool +} + +// NewColorScheme creates a ColorScheme. When enabled is false, all methods return +// undecorated text. +func NewColorScheme(enabled bool) *ColorScheme { + return &ColorScheme{enabled: enabled} +} + +// colorize wraps text in ANSI escape codes if color is enabled. +func (cs *ColorScheme) colorize(code string, text string) string { + if !cs.enabled { + return text + } + return fmt.Sprintf("\033[%sm%s\033[0m", code, text) +} + +// Bold renders text in bold. +func (cs *ColorScheme) Bold(s string) string { + return cs.colorize("1", s) +} + +// Red renders text in red. +func (cs *ColorScheme) Red(s string) string { + return cs.colorize("31", s) +} + +// Green renders text in green. +func (cs *ColorScheme) Green(s string) string { + return cs.colorize("32", s) +} + +// Yellow renders text in yellow. +func (cs *ColorScheme) Yellow(s string) string { + return cs.colorize("33", s) +} + +// Cyan renders text in cyan. +func (cs *ColorScheme) Cyan(s string) string { + return cs.colorize("36", s) +} + +// Magenta renders text in magenta. +func (cs *ColorScheme) Magenta(s string) string { + return cs.colorize("35", s) +} + +// Muted renders text in gray (dimmed). +func (cs *ColorScheme) Muted(s string) string { + return cs.colorize("90", s) +} + +// SuccessIcon returns a green check mark if color is enabled, plain otherwise. +func (cs *ColorScheme) SuccessIcon() string { + return cs.Green("✓") +} + +// WarningIcon returns a yellow exclamation mark if color is enabled, plain otherwise. +func (cs *ColorScheme) WarningIcon() string { + return cs.Yellow("!") +} + +// FailureIcon returns a red X mark if color is enabled, plain otherwise. +func (cs *ColorScheme) FailureIcon() string { + return cs.Red("✗") +} + +// SuccessIconWithColor returns the success icon followed by the message in green. +func (cs *ColorScheme) SuccessIconWithColor(msg string) string { + return cs.SuccessIcon() + " " + cs.Green(msg) +} diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go new file mode 100644 index 0000000..88561ad --- /dev/null +++ b/internal/iostreams/iostreams.go @@ -0,0 +1,272 @@ +package iostreams + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/term" +) + +// IOStreams provides the standard streams for the CLI along with TTY detection, +// color support, pager integration, and other terminal helpers. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + + // Private fields for state + isStdinTTY bool + isStdoutTTY bool + isStderrTTY bool + + pagerProcess *exec.Cmd + pagerPipe io.WriteCloser + originalOut io.Writer + + colorScheme *ColorScheme + + spinnerMu sync.Mutex + spinnerCancel chan struct{} +} + +// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr, +// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to +// be treated as TTYs. +func New() *IOStreams { + forceTTY := os.Getenv("FGJ_FORCE_TTY") != "" + + stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd())) + stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd())) + stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd())) + + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + isStdinTTY: stdinTTY, + isStdoutTTY: stdoutTTY, + isStderrTTY: stderrTTY, + } +} + +// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests. +// All TTY flags are false. +func Test() *IOStreams { + return &IOStreams{ + In: &bytes.Buffer{}, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + isStdinTTY: false, + isStdoutTTY: false, + isStderrTTY: false, + } +} + +// IsStdinTTY reports whether standard input is connected to a terminal. +func (s *IOStreams) IsStdinTTY() bool { + return s.isStdinTTY +} + +// IsStdoutTTY reports whether standard output is connected to a terminal. +func (s *IOStreams) IsStdoutTTY() bool { + return s.isStdoutTTY +} + +// IsStderrTTY reports whether standard error is connected to a terminal. +func (s *IOStreams) IsStderrTTY() bool { + return s.isStderrTTY +} + +// TerminalWidth returns the width of the terminal connected to stdout. If stdout +// is not a terminal, it returns 80. +func (s *IOStreams) TerminalWidth() int { + if !s.isStdoutTTY { + return 80 + } + if f, ok := s.Out.(*os.File); ok { + w, _, err := term.GetSize(int(f.Fd())) + if err == nil && w > 0 { + return w + } + } + return 80 +} + +// ColorEnabled returns true when color output should be used. Color is enabled +// when stdout is a TTY and the NO_COLOR environment variable is not set. +func (s *IOStreams) ColorEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + return s.isStdoutTTY +} + +// ColorScheme returns a lazily-initialized ColorScheme that respects the current +// color settings. +func (s *IOStreams) ColorScheme() *ColorScheme { + if s.colorScheme == nil { + s.colorScheme = NewColorScheme(s.ColorEnabled()) + } + return s.colorScheme +} + +// StartPager starts an external pager process and redirects Out to its stdin. +// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not +// already set, it is set to "FRX" for a good default experience. +func (s *IOStreams) StartPager() error { + if !s.isStdoutTTY { + return nil + } + + pagerCmd := os.Getenv("FGJ_PAGER") + if pagerCmd == "" { + pagerCmd = os.Getenv("PAGER") + } + if pagerCmd == "" { + pagerCmd = "less" + } + + if os.Getenv("LESS") == "" { + os.Setenv("LESS", "FRX") + } + + parts := strings.Fields(pagerCmd) + //nolint:gosec // pager command is user-configured + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdout = s.Out + cmd.Stderr = s.ErrOut + + pipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("creating pager pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting pager: %w", err) + } + + s.pagerProcess = cmd + s.pagerPipe = pipe + s.originalOut = s.Out + s.Out = pipe + + return nil +} + +// StopPager closes the pager's stdin pipe and waits for the process to exit. +// It restores Out to the original writer. +func (s *IOStreams) StopPager() { + if s.pagerPipe == nil { + return + } + + _ = s.pagerPipe.Close() + _ = s.pagerProcess.Wait() + + s.Out = s.originalOut + s.pagerPipe = nil + s.pagerProcess = nil + s.originalOut = nil +} + +// spinnerFrames are the Braille-based animation frames for the spinner. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// StartSpinner shows an animated spinner with the given label on stderr. It only +// runs when stderr is a TTY. Call StopSpinner to halt it. +func (s *IOStreams) StartSpinner(label string) { + if !s.isStderrTTY { + return + } + + s.spinnerMu.Lock() + defer s.spinnerMu.Unlock() + + // Stop any existing spinner first. + if s.spinnerCancel != nil { + close(s.spinnerCancel) + s.spinnerCancel = nil + } + + cancel := make(chan struct{}) + s.spinnerCancel = cancel + + go func() { + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + i := 0 + for { + select { + case <-cancel: + // Clear the spinner line. + fmt.Fprintf(s.ErrOut, "\r\033[K") + return + case <-ticker.C: + frame := spinnerFrames[i%len(spinnerFrames)] + fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label) + i++ + } + } + }() +} + +// StopSpinner halts the spinner and clears the line on stderr. +func (s *IOStreams) StopSpinner() { + s.spinnerMu.Lock() + defer s.spinnerMu.Unlock() + + if s.spinnerCancel != nil { + close(s.spinnerCancel) + s.spinnerCancel = nil + } +} + +// OpenInBrowser opens the given URL in the user's default browser. +func (s *IOStreams) OpenInBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: // linux, freebsd, etc. + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +// ConfirmAction prompts the user with a yes/no question and returns their +// answer. It returns an error if stdin is not a TTY (non-interactive). +func (s *IOStreams) ConfirmAction(prompt string) (bool, error) { + if !s.isStdinTTY { + return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal") + } + + fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt) + + var response string + if _, err := fmt.Fscan(s.In, &response); err != nil { + return false, fmt.Errorf("reading response: %w", err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes", nil +} + +// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output. +func (s *IOStreams) NewTablePrinter() *TablePrinter { + return NewTablePrinter(s) +} + +// isTerminal reports whether the given file descriptor is a terminal. +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} diff --git a/internal/iostreams/table.go b/internal/iostreams/table.go new file mode 100644 index 0000000..a97f711 --- /dev/null +++ b/internal/iostreams/table.go @@ -0,0 +1,64 @@ +package iostreams + +import ( + "fmt" + "strings" + "text/tabwriter" +) + +// TablePrinter prints TTY-aware tables. In TTY mode it uses aligned columns with +// bold headers. In pipe mode it emits tab-separated values without headers. +type TablePrinter struct { + ios *IOStreams + headers []string + rows [][]string +} + +// NewTablePrinter creates a TablePrinter that writes to ios.Out. +func NewTablePrinter(ios *IOStreams) *TablePrinter { + return &TablePrinter{ + ios: ios, + } +} + +// AddHeader sets the column headers. Headers are only displayed in TTY mode. +func (t *TablePrinter) AddHeader(headers ...string) { + t.headers = headers +} + +// AddRow appends a row of fields to the table. +func (t *TablePrinter) AddRow(fields ...string) { + t.rows = append(t.rows, fields) +} + +// Render writes the table to the IOStreams output. In TTY mode it uses tabwriter +// with bold headers. In pipe mode it emits tab-separated values without headers. +func (t *TablePrinter) Render() error { + if !t.ios.IsStdoutTTY() { + // Pipe mode: tab-separated, no headers + for _, row := range t.rows { + if _, err := fmt.Fprintln(t.ios.Out, strings.Join(row, "\t")); err != nil { + return err + } + } + return nil + } + + // TTY mode: use tabwriter with aligned columns + w := tabwriter.NewWriter(t.ios.Out, 0, 0, 2, ' ', 0) + + if len(t.headers) > 0 { + cs := t.ios.ColorScheme() + boldHeaders := make([]string, len(t.headers)) + for i, h := range t.headers { + boldHeaders[i] = cs.Bold(h) + } + fmt.Fprintln(w, strings.Join(boldHeaders, "\t")) + } + + for _, row := range t.rows { + fmt.Fprintln(w, strings.Join(row, "\t")) + } + + return w.Flush() +} diff --git a/internal/text/text.go b/internal/text/text.go new file mode 100644 index 0000000..06e631c --- /dev/null +++ b/internal/text/text.go @@ -0,0 +1,70 @@ +package text + +import ( + "fmt" + "math" + "time" +) + +// Pluralize returns "1 issue" or "2 issues" depending on count. +// It applies a simple "s" suffix rule. +func Pluralize(count int, singular string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %ss", count, singular) +} + +// FuzzyAgo returns a human-friendly relative time string like "just now", +// "2 minutes ago", "3 hours ago", etc. +func FuzzyAgo(t time.Time) string { + d := time.Since(t) + + if d < time.Minute { + return "just now" + } + + minutes := int(math.Floor(d.Minutes())) + if minutes < 60 { + return fmt.Sprintf("%s ago", Pluralize(minutes, "minute")) + } + + hours := int(math.Floor(d.Hours())) + if hours < 24 { + return fmt.Sprintf("%s ago", Pluralize(hours, "hour")) + } + + days := hours / 24 + if days < 30 { + return fmt.Sprintf("%s ago", Pluralize(days, "day")) + } + + months := days / 30 + if months < 12 { + return fmt.Sprintf("%s ago", Pluralize(months, "month")) + } + + years := months / 12 + return fmt.Sprintf("%s ago", Pluralize(years, "year")) +} + +// Truncate shortens text to maxWidth, replacing the end with "..." if it exceeds +// the limit. If maxWidth is less than or equal to 3, the result is just "...". +func Truncate(text string, maxWidth int) string { + if len(text) <= maxWidth { + return text + } + if maxWidth <= 3 { + return "..."[:maxWidth] + } + return text[:maxWidth-3] + "..." +} + +// FormatDate returns a human-friendly relative time for TTY output, or an +// RFC3339 timestamp for piped output. +func FormatDate(t time.Time, isTTY bool) string { + if isTTY { + return FuzzyAgo(t) + } + return t.Format(time.RFC3339) +} diff --git a/main.go b/main.go index 76dd977..b3b29a0 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( func main() { if err := cmd.Execute(); err != nil { + err = cmd.ContextualError(err) if cmd.JSONErrors() { cmd.WriteJSONError(err) } else {