feat: v0.3.0d — add PR checks, iostreams, aliases, and broad enhancements

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.
This commit is contained in:
sid 2026-03-23 11:42:44 -06:00
parent 7c0dcc8696
commit 113505de95
29 changed files with 3131 additions and 542 deletions

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ config.yaml
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
# Workspace (scratch data, cloned repos, analysis)
.workspace/

View file

@ -279,6 +279,11 @@ fgj repo edit owner/repo --public
fgj repo edit owner/repo --private fgj repo edit owner/repo --private
fgj repo edit owner/repo -d "New description" --homepage https://example.com fgj repo edit owner/repo -d "New description" --homepage https://example.com
fgj repo edit --default-branch develop 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 ### Releases
@ -532,7 +537,7 @@ Contributions are welcome! Please feel free to submit a Pull Request at [forgejo
- `pr checks`, `pr ready/draft` - `pr checks`, `pr ready/draft`
- `issue reopen`, `issue assign` - `issue reopen`, `issue assign`
- `release edit`, `release download`, `release generate-notes` - `release edit`, `release download`, `release generate-notes`
- `repo delete`, `repo rename` - `repo delete`
We welcome contributions to implement any of these features! We welcome contributions to implement any of these features!

View file

@ -3,10 +3,8 @@ package cmd
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"text/tabwriter"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
@ -100,39 +98,67 @@ var runListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List recent workflow runs", Short: "List recent workflow runs",
Long: "List recent workflow runs for a repository.", 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{ var runViewCmd = &cobra.Command{
Use: "view <run-id>", Use: "view <run-id>",
Short: "View a workflow run", Short: "View a workflow run",
Long: "View details about a specific workflow run.", Long: "View details about a specific workflow run.",
Args: cobra.ExactArgs(1), Example: ` # View a workflow run
RunE: runRunView, 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{ var runWatchCmd = &cobra.Command{
Use: "watch <run-id>", Use: "watch <run-id>",
Short: "Watch a workflow run", Short: "Watch a workflow run",
Long: "Poll a workflow run until it completes.", Long: "Poll a workflow run until it completes.",
Args: cobra.ExactArgs(1), Example: ` # Watch a run until it completes
RunE: runRunWatch, 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{ var runRerunCmd = &cobra.Command{
Use: "rerun <run-id>", Use: "rerun <run-id>",
Short: "Rerun a workflow run", Short: "Rerun a workflow run",
Long: "Trigger a rerun for a specific workflow run.", Long: "Trigger a rerun for a specific workflow run.",
Args: cobra.ExactArgs(1), Example: ` # Rerun a failed workflow run
RunE: runRunRerun, fgj actions run rerun 123`,
Args: cobra.ExactArgs(1),
RunE: runRunRerun,
} }
var runCancelCmd = &cobra.Command{ var runCancelCmd = &cobra.Command{
Use: "cancel <run-id>", Use: "cancel <run-id>",
Short: "Cancel a workflow run", Short: "Cancel a workflow run",
Long: "Cancel a running workflow run.", Long: "Cancel a running workflow run.",
Args: cobra.ExactArgs(1), Example: ` # Cancel a running workflow
RunE: runRunCancel, fgj actions run cancel 123`,
Args: cobra.ExactArgs(1),
RunE: runRunCancel,
} }
// Workflow commands // Workflow commands
@ -146,39 +172,61 @@ var workflowListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List workflows", Short: "List workflows",
Long: "List all workflows in a repository.", 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{ var workflowViewCmd = &cobra.Command{
Use: "view <workflow>", Use: "view <workflow>",
Short: "View a workflow", Short: "View a workflow",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.", Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Args: cobra.ExactArgs(1), Example: ` # View a workflow by filename
RunE: runWorkflowView, 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{ var workflowRunCmd = &cobra.Command{
Use: "run <workflow>", Use: "run <workflow>",
Short: "Run a workflow", Short: "Run a workflow",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Args: cobra.ExactArgs(1), Example: ` # Trigger a workflow on the default branch
RunE: runWorkflowRun, 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{ var workflowEnableCmd = &cobra.Command{
Use: "enable <workflow>", Use: "enable <workflow>",
Short: "Enable a workflow", 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.", 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), Example: ` # Enable a workflow
RunE: runWorkflowEnable, fgj actions workflow enable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowEnable,
} }
var workflowDisableCmd = &cobra.Command{ var workflowDisableCmd = &cobra.Command{
Use: "disable <workflow>", Use: "disable <workflow>",
Short: "Disable a workflow", 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.", 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), Example: ` # Disable a workflow
RunE: runWorkflowDisable, fgj actions workflow disable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowDisable,
} }
// Secret commands // Secret commands
@ -192,23 +240,35 @@ var actionsSecretListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List repository secrets", Short: "List repository secrets",
Long: "List all secrets for a repository.", 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{ var actionsSecretCreateCmd = &cobra.Command{
Use: "create <name>", Use: "create <name>",
Short: "Create or update a repository secret", Short: "Create or update a repository secret",
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.", Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
Args: cobra.ExactArgs(1), Example: ` # Create a secret (will prompt for value)
RunE: runActionsSecretCreate, 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{ var actionsSecretDeleteCmd = &cobra.Command{
Use: "delete <name>", Use: "delete <name>",
Short: "Delete a repository secret", Short: "Delete a repository secret",
Long: "Delete a secret from Forgejo Actions.", Long: "Delete a secret from Forgejo Actions.",
Args: cobra.ExactArgs(1), Example: ` # Delete a secret
RunE: runActionsSecretDelete, fgj actions secret delete DEPLOY_TOKEN`,
Args: cobra.ExactArgs(1),
RunE: runActionsSecretDelete,
} }
// Variable commands // Variable commands
@ -222,39 +282,55 @@ var actionsVariableListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List repository variables", Short: "List repository variables",
Long: "List all variables for a repository.", 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{ var actionsVariableGetCmd = &cobra.Command{
Use: "get <name>", Use: "get <name>",
Short: "Get a repository variable", Short: "Get a repository variable",
Long: "Get the value of a specific repository variable.", Long: "Get the value of a specific repository variable.",
Args: cobra.ExactArgs(1), Example: ` # Get a variable value
RunE: runActionsVariableGet, fgj actions variable get ENVIRONMENT`,
Args: cobra.ExactArgs(1),
RunE: runActionsVariableGet,
} }
var actionsVariableCreateCmd = &cobra.Command{ var actionsVariableCreateCmd = &cobra.Command{
Use: "create <name> <value>", Use: "create <name> <value>",
Short: "Create a repository variable", Short: "Create a repository variable",
Long: "Create a new variable for Forgejo Actions.", Long: "Create a new variable for Forgejo Actions.",
Args: cobra.ExactArgs(2), Example: ` # Create a variable
RunE: runActionsVariableCreate, 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{ var actionsVariableUpdateCmd = &cobra.Command{
Use: "update <name> <value>", Use: "update <name> <value>",
Short: "Update a repository variable", Short: "Update a repository variable",
Long: "Update an existing variable for Forgejo Actions.", Long: "Update an existing variable for Forgejo Actions.",
Args: cobra.ExactArgs(2), Example: ` # Update a variable
RunE: runActionsVariableUpdate, fgj actions variable update ENVIRONMENT staging`,
Args: cobra.ExactArgs(2),
RunE: runActionsVariableUpdate,
} }
var actionsVariableDeleteCmd = &cobra.Command{ var actionsVariableDeleteCmd = &cobra.Command{
Use: "delete <name>", Use: "delete <name>",
Short: "Delete a repository variable", Short: "Delete a repository variable",
Long: "Delete a variable from Forgejo Actions.", Long: "Delete a variable from Forgejo Actions.",
Args: cobra.ExactArgs(1), Example: ` # Delete a variable
RunE: runActionsVariableDelete, fgj actions variable delete ENVIRONMENT`,
Args: cobra.ExactArgs(1),
RunE: runActionsVariableDelete,
} }
func init() { func init() {
@ -293,13 +369,13 @@ func init() {
// Add flags for run commands // Add flags for run commands
addRepoFlags(runListCmd) addRepoFlags(runListCmd)
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") 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) addRepoFlags(runViewCmd)
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") 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().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().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().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) addRepoFlags(runWatchCmd)
runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
addRepoFlags(runRerunCmd) addRepoFlags(runRerunCmd)
@ -308,9 +384,9 @@ func init() {
// Add flags for workflow commands // Add flags for workflow commands
addRepoFlags(workflowListCmd) addRepoFlags(workflowListCmd)
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") 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) addRepoFlags(workflowViewCmd)
workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addJSONFlags(workflowViewCmd, "Output workflow as JSON")
addRepoFlags(workflowRunCmd) addRepoFlags(workflowRunCmd)
addRepoFlags(workflowEnableCmd) addRepoFlags(workflowEnableCmd)
addRepoFlags(workflowDisableCmd) addRepoFlags(workflowDisableCmd)
@ -325,6 +401,7 @@ func init() {
// Add flags for variable commands // Add flags for variable commands
addRepoFlags(actionsVariableListCmd) addRepoFlags(actionsVariableListCmd)
addJSONFlags(actionsVariableListCmd, "Output variables as JSON")
addRepoFlags(actionsVariableGetCmd) addRepoFlags(actionsVariableGetCmd)
addRepoFlags(actionsVariableCreateCmd) addRepoFlags(actionsVariableCreateCmd)
addRepoFlags(actionsVariableUpdateCmd) addRepoFlags(actionsVariableUpdateCmd)
@ -364,39 +441,26 @@ func runRunList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list runs: %w", err) return fmt.Errorf("failed to list runs: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(runList.WorkflowRuns) return outputJSON(cmd, runList.WorkflowRuns)
} }
if len(runList.WorkflowRuns) == 0 { if len(runList.WorkflowRuns) == 0 {
fmt.Println("No workflow runs found") fmt.Fprintln(ios.Out, "No workflow runs found")
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
if _, err := fmt.Fprintln(w, "STATUS\tTITLE\tWORKFLOW\tEVENT\tID\tCREATED"); err != nil { tp.AddHeader("STATUS", "TITLE", "WORKFLOW", "EVENT", "ID", "CREATED")
return fmt.Errorf("failed to write header: %w", err)
}
for _, run := range runList.WorkflowRuns { for _, run := range runList.WorkflowRuns {
createdTime, err := time.Parse(time.RFC3339, run.Created) createdTime, err := time.Parse(time.RFC3339, run.Created)
if err != nil { if err != nil {
createdTime = time.Now() createdTime = time.Now()
} }
timeStr := formatTimeSince(createdTime) timeStr := formatTimeSince(createdTime)
tp.AddRow(formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, fmt.Sprintf("%d", run.ID), timeStr)
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)
}
} }
return tp.Render()
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to flush output: %w", err)
}
return nil
} }
func runRunView(cmd *cobra.Command, args []string) error { 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") showLog, _ := cmd.Flags().GetBool("log")
jobIDStr, _ := cmd.Flags().GetString("job") jobIDStr, _ := cmd.Flags().GetString("job")
showLogFailed, _ := cmd.Flags().GetBool("log-failed") showLogFailed, _ := cmd.Flags().GetBool("log-failed")
jsonOutput, _ := cmd.Flags().GetBool("json") jsonRequested := wantJSON(cmd)
var jobID int64 var jobID int64
if jobIDStr != "" { 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") 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 needsJobs := verbose || showLog || showLogFailed || jobID > 0
if jsonOutput { if jsonRequested {
var runTasks []ActionTask var runTasks []ActionTask
if needsJobs { if needsJobs {
tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) 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, Run: run,
Tasks: runTasks, Tasks: runTasks,
} }
return writeJSON(payload) return outputJSON(cmd, payload)
} }
// Display run information // Display run information
fmt.Printf("Title: %s\n", run.Title) fmt.Fprintf(ios.Out, "Title: %s\n", run.Title)
fmt.Printf("Workflow: %s\n", run.WorkflowID) fmt.Fprintf(ios.Out, "Workflow: %s\n", run.WorkflowID)
fmt.Printf("Run: #%d\n", run.IndexInRepo) fmt.Fprintf(ios.Out, "Run: #%d\n", run.IndexInRepo)
fmt.Printf("Status: %s\n", formatStatus(run.Status)) fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(run.Status))
fmt.Printf("Event: %s\n", run.Event) fmt.Fprintf(ios.Out, "Event: %s\n", run.Event)
fmt.Printf("Ref: %s\n", run.PrettyRef) fmt.Fprintf(ios.Out, "Ref: %s\n", run.PrettyRef)
commit := run.CommitSHA commit := run.CommitSHA
if len(commit) > 8 { if len(commit) > 8 {
commit = 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 { 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 run.Started != "" {
if startedTime, err := time.Parse(time.RFC3339, run.Started); err == nil { 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 { 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 // 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 { if len(runTasks) == 0 {
fmt.Println("\nNo jobs found for this run") fmt.Fprintln(ios.Out, "\nNo jobs found for this run")
return nil return nil
} }
@ -557,14 +621,14 @@ func runRunView(cmd *cobra.Command, args []string) error {
// Case 1: --verbose (show job steps/details without logs) // Case 1: --verbose (show job steps/details without logs)
if verbose && !showLog && !showLogFailed { if verbose && !showLog && !showLogFailed {
fmt.Println("\nJobs:") fmt.Fprintln(ios.Out, "\nJobs:")
for _, task := range runTasks { 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 { 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 { 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 return nil
@ -574,7 +638,7 @@ func runRunView(cmd *cobra.Command, args []string) error {
if showLog || showLogFailed { if showLog || showLogFailed {
for _, task := range runTasks { for _, task := range runTasks {
if err := showJobLog(client, owner, name, task, showLogFailed); err != nil { if err := showJobLog(client, owner, name, task, showLogFailed); err != nil {
fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) fmt.Fprintf(ios.Out, "\nError fetching log for job %s: %v\n", task.Name, err)
} }
} }
return nil 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) // Case 3: --job without --log or --verbose (show job details only)
if jobID > 0 { if jobID > 0 {
task := runTasks[0] task := runTasks[0]
fmt.Println("\nJob Details:") fmt.Fprintln(ios.Out, "\nJob Details:")
fmt.Printf(" Name: %s\n", task.Name) fmt.Fprintf(ios.Out, " Name: %s\n", task.Name)
fmt.Printf(" ID: %d\n", task.ID) fmt.Fprintf(ios.Out, " ID: %d\n", task.ID)
fmt.Printf(" Status: %s\n", formatStatus(task.Status)) fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(task.Status))
if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { 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 { 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 { 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 lastStatus = run.Status
} }
if isRunComplete(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 return nil
} }
@ -675,7 +739,7 @@ func runRunRerun(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to rerun workflow: %w", err) 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 return nil
} }
@ -706,7 +770,7 @@ func runRunCancel(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to cancel workflow run: %w", err) 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 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", logURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/actions/jobs/%d/logs",
client.Hostname(), owner, name, task.ID) client.Hostname(), owner, name, task.ID)
fmt.Printf("\n========================================\n") fmt.Fprintf(ios.Out, "\n========================================\n")
fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) fmt.Fprintf(ios.Out, "Job: %s (ID: %d)\n", task.Name, task.ID)
fmt.Printf("Status: %s\n", formatStatus(task.Status)) fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(task.Status))
fmt.Printf("========================================\n\n") fmt.Fprintf(ios.Out, "========================================\n\n")
// Use GetRawLog helper // Use GetRawLog helper
logContent, err := client.GetRawLog(logURL) logContent, err := client.GetRawLog(logURL)
@ -731,11 +795,11 @@ func showJobLog(client *api.Client, owner, name string, task ActionTask, logFail
if logFailed { if logFailed {
// TODO: Implement filtering for failed steps only // TODO: Implement filtering for failed steps only
// This would require parsing the log format and identifying failed step markers // 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.Fprint(ios.Out, logContent)
fmt.Println() fmt.Fprintln(ios.Out)
return nil return nil
} }
@ -848,34 +912,25 @@ func runWorkflowList(cmd *cobra.Command, args []string) error {
} }
if len(allWorkflows) == 0 { if len(allWorkflows) == 0 {
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(allWorkflows) return outputJSON(cmd, allWorkflows)
} }
fmt.Println("No workflows found") fmt.Fprintln(ios.Out, "No workflows found")
return nil return nil
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(allWorkflows) return outputJSON(cmd, allWorkflows)
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { tp.AddHeader("NAME", "STATE", "PATH")
return fmt.Errorf("failed to write header: %w", err)
}
for _, workflow := range allWorkflows { for _, workflow := range allWorkflows {
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", tp.AddRow(workflow.Name, workflow.State, workflow.Path)
workflow.Name, workflow.State, workflow.Path); err != nil {
return fmt.Errorf("failed to write workflow: %w", err)
}
} }
if err := w.Flush(); err != nil { return tp.Render()
return fmt.Errorf("failed to flush output: %w", err)
}
return nil
} }
func runWorkflowView(cmd *cobra.Command, args []string) error { func runWorkflowView(cmd *cobra.Command, args []string) error {
@ -901,8 +956,6 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
return err return err
} }
jsonOutput, _ := cmd.Flags().GetBool("json")
var latestRun *ActionRun var latestRun *ActionRun
// Get the latest run for this workflow // Get the latest run for this workflow
@ -912,7 +965,7 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
latestRun = &runList.WorkflowRuns[0] latestRun = &runList.WorkflowRuns[0]
} }
if jsonOutput { if wantJSON(cmd) {
payload := struct { payload := struct {
Workflow *Workflow `json:"workflow"` Workflow *Workflow `json:"workflow"`
LatestRun *ActionRun `json:"latest_run,omitempty"` LatestRun *ActionRun `json:"latest_run,omitempty"`
@ -920,21 +973,21 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
Workflow: workflow, Workflow: workflow,
LatestRun: latestRun, LatestRun: latestRun,
} }
return writeJSON(payload) return outputJSON(cmd, payload)
} }
// Display workflow information // Display workflow information
fmt.Printf("Name: %s\n", workflow.Name) fmt.Fprintf(ios.Out, "Name: %s\n", workflow.Name)
fmt.Printf("Path: %s\n", workflow.Path) fmt.Fprintf(ios.Out, "Path: %s\n", workflow.Path)
fmt.Printf("State: %s\n", workflow.State) fmt.Fprintf(ios.Out, "State: %s\n", workflow.State)
if latestRun != nil { if latestRun != nil {
fmt.Printf("\nLatest run:\n") fmt.Fprintf(ios.Out, "\nLatest run:\n")
fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(latestRun.Status))
fmt.Printf(" Event: %s\n", latestRun.Event) fmt.Fprintf(ios.Out, " Event: %s\n", latestRun.Event)
fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) fmt.Fprintf(ios.Out, " Ref: %s\n", latestRun.PrettyRef)
if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { 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) return fmt.Errorf("failed to trigger workflow: %w", err)
} }
fmt.Printf("✓ Workflow '%s' triggered successfully\n", workflowIdentifier) fmt.Fprintf(ios.Out, "✓ Workflow '%s' triggered successfully\n", workflowIdentifier)
fmt.Printf(" Branch/Tag: %s\n", ref) fmt.Fprintf(ios.Out, " Branch/Tag: %s\n", ref)
if len(inputs) > 0 { if len(inputs) > 0 {
fmt.Println(" Inputs:") fmt.Fprintln(ios.Out, " Inputs:")
for key, value := range 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) 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 return nil
} }
@ -1104,7 +1157,7 @@ func runWorkflowDisable(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to disable workflow: %w", err) 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 return nil
} }
@ -1170,24 +1223,16 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error {
} }
if len(secrets) == 0 { if len(secrets) == 0 {
fmt.Println("No secrets found") fmt.Fprintln(ios.Out, "No secrets found")
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
if _, err := fmt.Fprintln(w, "NAME\tCREATED"); err != nil { tp.AddHeader("NAME", "CREATED")
return fmt.Errorf("failed to write header: %w", err)
}
for _, secret := range secrets { 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 { tp.AddRow(secret.Name, secret.Created.Format("2006-01-02 15:04:05"))
return fmt.Errorf("failed to write secret: %w", err)
}
} }
if err := w.Flush(); err != nil { return tp.Render()
return fmt.Errorf("failed to flush output: %w", err)
}
return nil
} }
func runActionsSecretCreate(cmd *cobra.Command, args []string) error { func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
@ -1210,7 +1255,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
secretName := args[0] secretName := args[0]
// Read secret value from stdin // Read secret value from stdin
fmt.Print("Enter secret value: ") fmt.Fprint(ios.ErrOut, "Enter secret value: ")
var secretValue string var secretValue string
_, err = fmt.Scanln(&secretValue) _, err = fmt.Scanln(&secretValue)
if err != nil { if err != nil {
@ -1227,7 +1272,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create secret: %w", err) 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 return nil
} }
@ -1255,15 +1300,57 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to delete secret: %w", err) 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 return nil
} }
// Variable command implementations // 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 { func runActionsVariableList(cmd *cobra.Command, args []string) error {
// Note: The SDK doesn't have a ListRepoActionVariable method yet cfg, err := config.Load()
return fmt.Errorf("listing variables is not yet supported in the SDK") 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 { 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) 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 return nil
} }
@ -1319,7 +1406,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create variable: %w", err) 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 return nil
} }
@ -1348,7 +1435,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to update variable: %w", err) 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 return nil
} }
@ -1376,6 +1463,6 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to delete variable: %w", err) 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 return nil
} }

142
cmd/aliases.go Normal file
View file

@ -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 <run-id>",
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 <run-id>",
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 <run-id>",
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 <run-id>",
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 <workflow>",
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 <workflow>",
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 <workflow>",
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 <workflow>",
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)
}

View file

@ -171,8 +171,10 @@ func runAPI(cmd *cobra.Command, args []string) error {
} }
// Execute request // Execute request
ios.StartSpinner("Requesting...")
httpClient := &http.Client{} httpClient := &http.Client{}
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to perform request: %w", err) 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 // Print response headers if requested
if include { 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 key, values := range resp.Header {
for _, v := range values { 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 // Read response body
@ -198,12 +200,12 @@ func runAPI(cmd *cobra.Command, args []string) error {
// Handle non-2xx status codes // Handle non-2xx status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if !silent { if !silent {
fmt.Fprint(os.Stderr, string(respBody)) fmt.Fprint(ios.ErrOut, string(respBody))
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' { 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 { 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) { if strings.Contains(contentType, "json") || json.Valid(respBody) {
var parsed any var parsed any
if err := json.Unmarshal(respBody, &parsed); err == nil { if err := json.Unmarshal(respBody, &parsed); err == nil {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(parsed) return enc.Encode(parsed)
} }
} }
// Raw output for non-JSON responses // Raw output for non-JSON responses
_, err = os.Stdout.Write(respBody) _, err = ios.Out.Write(respBody)
return err return err
} }

View file

@ -68,7 +68,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
if hostname == "" { if hostname == "" {
fmt.Print("Forgejo instance hostname (default: codeberg.org): ") fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ")
input, _ := reader.ReadString('\n') input, _ := reader.ReadString('\n')
hostname = strings.TrimSpace(input) hostname = strings.TrimSpace(input)
if hostname == "" { if hostname == "" {
@ -77,12 +77,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
} }
if token == "" { if token == "" {
fmt.Print("Personal access token: ") fmt.Fprint(ios.ErrOut, "Personal access token: ")
tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return fmt.Errorf("failed to read token: %w", err) return fmt.Errorf("failed to read token: %w", err)
} }
fmt.Println() fmt.Fprintln(ios.ErrOut)
token = strings.TrimSpace(string(tokenBytes)) 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) return fmt.Errorf("failed to create client: %w", err)
} }
ios.StartSpinner("Authenticating...")
user, _, err := client.GetMyUserInfo() user, _, err := client.GetMyUserInfo()
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("authentication failed: %w", err) 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) 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 return nil
} }
@ -128,14 +131,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
} }
if len(cfg.Hosts) == 0 { if len(cfg.Hosts) == 0 {
fmt.Println("Not authenticated with any Forgejo instances") fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
fmt.Println("Run 'fgj auth login' to authenticate") fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate")
return nil return nil
} }
fmt.Println("Authenticated instances:") fmt.Fprintln(ios.Out, "Authenticated instances:")
for hostname, host := range cfg.Hosts { 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 return nil
@ -158,7 +162,8 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save config: %w", err) 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 return nil
} }
@ -174,7 +179,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error {
return err return err
} }
fmt.Println(cfg.Hosts[resolved].Token) fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token)
return nil return nil
} }

View file

@ -3,7 +3,8 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"os" "fmt"
"strings"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "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} 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. // writeJSONError writes a structured JSON error to stderr.
// It attempts to extract structured info from known error types. // It attempts to extract structured info from known error types.
// WriteJSONError writes a structured JSON error to stderr. // 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.SetIndent("", " ")
_ = enc.Encode(cliErr) _ = enc.Encode(cliErr)
} }

5
cmd/ios_init.go Normal file
View file

@ -0,0 +1,5 @@
package cmd
import "forgejo.zerova.net/sid/fgj-sid/internal/iostreams"
var ios = iostreams.New()

View file

@ -3,14 +3,12 @@ package cmd
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"strconv"
"strings" "strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -24,46 +22,114 @@ var issueListCmd = &cobra.Command{
Use: "list [flags]", Use: "list [flags]",
Short: "List issues", Short: "List issues",
Long: "List issues in a repository.", 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{ var issueViewCmd = &cobra.Command{
Use: "view <number>", Use: "view <number>",
Short: "View an issue", Short: "View an issue",
Long: "Display detailed information about an issue.", Long: "Display detailed information about an issue.",
Args: cobra.ExactArgs(1), Example: ` # View issue #42
RunE: runIssueView, 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{ var issueCreateCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Create an issue", Short: "Create an issue",
Long: "Create a new 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{ var issueCommentCmd = &cobra.Command{
Use: "comment <number>", Use: "comment <number>",
Short: "Add a comment to an issue", Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.", Long: "Add a comment to an existing issue.",
Args: cobra.ExactArgs(1), Example: ` # Add a comment to issue #42
RunE: runIssueComment, 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{ var issueCloseCmd = &cobra.Command{
Use: "close <number>", Use: "close <number>",
Short: "Close an issue", Short: "Close an issue",
Long: "Close an existing issue.", Long: "Close an existing issue.",
Args: cobra.ExactArgs(1), Example: ` # Close issue #42
RunE: runIssueClose, 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 <number>",
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 <number>",
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{ var issueEditCmd = &cobra.Command{
Use: "edit <number>", Use: "edit <number>",
Short: "Edit an issue", Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.", Long: "Edit an existing issue's title, body, or state.",
Args: cobra.ExactArgs(1), Example: ` # Update the title of issue #42
RunE: runIssueEdit, 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() { func init() {
@ -73,19 +139,31 @@ func init() {
issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueCommentCmd) issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueCloseCmd) issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueReopenCmd)
issueCmd.AddCommand(issueDeleteCmd)
issueCmd.AddCommand(issueEditCmd) 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("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") 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().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("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
issueCreateCmd.Flags().StringP("body", "b", "", "Body 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("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("repo", "R", "", "Repository in owner/name format")
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") 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("repo", "R", "", "Repository in owner/name format")
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") 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("repo", "R", "", "Repository in owner/name format")
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
issueEditCmd.Flags().StringP("body", "b", "", "New body 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 { func runIssueList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
state, _ := cmd.Flags().GetString("state") 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) owner, name, err := parseRepo(repo)
if err != nil { if err != nil {
@ -134,9 +220,16 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state) return fmt.Errorf("invalid state: %s", state)
} }
ios.StartSpinner("Fetching issues...")
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{ 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 { if err != nil {
return fmt.Errorf("failed to list issues: %w", err) 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 { if wantJSON(cmd) {
return writeJSON(nonPRIssues) return outputJSON(cmd, nonPRIssues)
} }
if len(nonPRIssues) == 0 { 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 return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") tp.AddHeader("NUMBER", "TITLE", "STATE")
for _, issue := range nonPRIssues { 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 tp.Render()
return nil
} }
func runIssueView(cmd *cobra.Command, args []string) error { func runIssueView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := strconv.ParseInt(args[0], 10, 64) issueNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid issue number: %w", err) return fmt.Errorf("invalid issue number: %w", err)
} }
@ -189,8 +280,10 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching issue...")
issue, _, err := client.GetIssue(owner, name, issueNumber) issue, _, err := client.GetIssue(owner, name, issueNumber)
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get issue: %w", err) return fmt.Errorf("failed to get issue: %w", err)
} }
@ -199,8 +292,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
comments = 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 { payload := struct {
Issue *gitea.Issue `json:"issue"` Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"` Comments []*gitea.Comment `json:"comments,omitempty"`
@ -208,26 +306,34 @@ func runIssueView(cmd *cobra.Command, args []string) error {
Issue: issue, Issue: issue,
Comments: comments, Comments: comments,
} }
return writeJSON(payload) return outputJSON(cmd, payload)
} }
fmt.Printf("Issue #%d\n", issue.Index) if err := ios.StartPager(); err != nil {
fmt.Printf("Title: %s\n", issue.Title) fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
fmt.Printf("State: %s\n", issue.State) }
fmt.Printf("Author: %s\n", issue.Poster.UserName) defer ios.StopPager()
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")) 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 != "" { if issue.Body != "" {
fmt.Printf("\n%s\n", issue.Body) fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
} }
if len(comments) > 0 { if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments)) fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
for _, comment := range 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.FullName,
comment.Poster.UserName, comment.Poster.UserName,
comment.Created.Format("2006-01-02 15:04:05"), text.FormatDate(comment.Created, isTTY),
comment.Body) comment.Body)
} }
} }
@ -240,14 +346,28 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
title, _ := cmd.Flags().GetString("title") title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body") body, _ := cmd.Flags().GetString("body")
labelNames, _ := cmd.Flags().GetStringSlice("label") labelNames, _ := cmd.Flags().GetStringSlice("label")
assignees, _ := cmd.Flags().GetStringSlice("assignee")
milestoneName, _ := cmd.Flags().GetString("milestone")
owner, name, err := parseRepo(repo) owner, name, err := parseRepo(repo)
if err != nil { if err != nil {
return err return err
} }
if title == "" { // Interactive mode: prompt for missing fields when TTY
return fmt.Errorf("title is required") 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() 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{ issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
Title: title, Title: title,
Body: body, Body: body,
Labels: labelIDs, Labels: labelIDs,
Assignees: resolvedAssignees,
Milestone: milestoneID,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
} }
fmt.Printf("Issue created: #%d\n", issue.Index) cs := ios.ColorScheme()
fmt.Printf("View at: %s\n", issue.HTMLURL) 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 return nil
} }
@ -286,7 +445,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
func runIssueComment(cmd *cobra.Command, args []string) error { func runIssueComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
body, _ := cmd.Flags().GetString("body") body, _ := cmd.Flags().GetString("body")
issueNumber, err := strconv.ParseInt(args[0], 10, 64) issueNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid issue number: %w", err) return fmt.Errorf("invalid issue number: %w", err)
} }
@ -310,15 +469,18 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Adding comment...")
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: body, Body: body,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create comment: %w", err) return fmt.Errorf("failed to create comment: %w", err)
} }
fmt.Printf("Comment added to issue #%d\n", issueNumber) cs := ios.ColorScheme()
fmt.Printf("View at: %s\n", comment.HTMLURL) 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 return nil
} }
@ -326,7 +488,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
func runIssueClose(cmd *cobra.Command, args []string) error { func runIssueClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment") commentBody, _ := cmd.Flags().GetString("comment")
issueNumber, err := strconv.ParseInt(args[0], 10, 64) issueNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid issue number: %w", err) return fmt.Errorf("invalid issue number: %w", err)
} }
@ -347,23 +509,28 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
} }
if commentBody != "" { if commentBody != "" {
ios.StartSpinner("Adding comment...")
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ _, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: commentBody, Body: commentBody,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create comment: %w", err) return fmt.Errorf("failed to create comment: %w", err)
} }
} }
ios.StartSpinner("Closing issue...")
stateClosed := gitea.StateClosed stateClosed := gitea.StateClosed
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateClosed, State: &stateClosed,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to close issue: %w", err) 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 return nil
} }
@ -378,7 +545,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency") addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency")
removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency") removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency")
issueNumber, err := strconv.ParseInt(args[0], 10, 64) issueNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid issue number: %w", err) 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 != "" { if title != "" || body != "" || stateStr != "" {
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt) _, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to edit issue: %w", err) 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 { if len(addLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames) labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
if err != nil { if err != nil {
ios.StopSpinner()
return err return err
} }
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{ _, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
Labels: labelIDs, Labels: labelIDs,
}) })
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add labels: %w", err) 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 { if len(removeLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames) labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
if err != nil { if err != nil {
ios.StopSpinner()
return err return err
} }
for _, labelID := range labelIDs { for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID) _, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove label %d: %w", labelID, err) return fmt.Errorf("failed to remove label %d: %w", labelID, err)
} }
} }
} }
ios.StopSpinner()
for _, depNumber := range addDeps { for _, depNumber := range addDeps {
depIssue, _, err := client.GetIssue(owner, name, depNumber) depIssue, _, err := client.GetIssue(owner, name, depNumber)
if err != nil { if err != nil {
@ -469,7 +645,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err) 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 { for _, depNumber := range removeDeps {
@ -483,10 +659,96 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err) 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 return nil
} }

View file

@ -2,11 +2,154 @@ package cmd
import ( import (
"encoding/json" "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 { func writeJSON(value any) error {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(value) 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
}

View file

@ -2,9 +2,7 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
@ -72,6 +70,9 @@ var labelDeleteCmd = &cobra.Command{
Example: ` # Delete a label Example: ` # Delete a label
fgj label delete bug fgj label delete bug
# Delete without confirmation
fgj label delete bug -y
# Delete a label from a specific repository # Delete a label from a specific repository
fgj label delete bug -R owner/repo`, fgj label delete bug -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
@ -86,20 +87,21 @@ func init() {
labelCmd.AddCommand(labelDeleteCmd) labelCmd.AddCommand(labelDeleteCmd)
labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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("repo", "R", "", "Repository in owner/name format")
labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)") labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)")
labelCreateCmd.Flags().StringP("description", "d", "", "Label description") 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().StringP("repo", "R", "", "Repository in owner/name format")
labelEditCmd.Flags().String("name", "", "New name for the label") labelEditCmd.Flags().String("name", "", "New name for the label")
labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)") labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)")
labelEditCmd.Flags().StringP("description", "d", "", "New description") 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().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) { func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) {
@ -144,29 +146,28 @@ func runLabelList(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching labels...")
labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{}) labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{})
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to list labels: %w", err) return fmt.Errorf("failed to list labels: %w", err)
} }
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, labels)
return writeJSON(labels)
} }
if len(labels) == 0 { if len(labels) == 0 {
fmt.Println("No labels found") fmt.Fprintln(ios.Out, "No labels found")
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
_, _ = fmt.Fprintf(w, "NAME\tCOLOR\tDESCRIPTION\n") tp.AddHeader("NAME", "COLOR", "DESCRIPTION")
for _, l := range labels { 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 tp.Render()
return nil
} }
func runLabelCreate(cmd *cobra.Command, args []string) error { func runLabelCreate(cmd *cobra.Command, args []string) error {
@ -179,21 +180,23 @@ func runLabelCreate(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Creating label...")
label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{ label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{
Name: labelName, Name: labelName,
Color: color, Color: color,
Description: description, Description: description,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create label: %w", err) return fmt.Errorf("failed to create label: %w", err)
} }
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, label)
return writeJSON(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 return nil
} }
@ -205,7 +208,9 @@ func runLabelEdit(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching label...")
existing, err := findLabelByName(client, owner, name, labelName) existing, err := findLabelByName(client, owner, name, labelName)
ios.StopSpinner()
if err != nil { if err != nil {
return err 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") 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) label, _, err := client.EditLabel(owner, name, existing.ID, opt)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to edit label: %w", err) return fmt.Errorf("failed to edit label: %w", err)
} }
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, label)
return writeJSON(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 return nil
} }
func runLabelDelete(cmd *cobra.Command, args []string) error { func runLabelDelete(cmd *cobra.Command, args []string) error {
labelName := args[0] labelName := args[0]
yes, _ := cmd.Flags().GetBool("yes")
client, owner, name, err := newLabelClient(cmd) client, owner, name, err := newLabelClient(cmd)
if err != nil { if err != nil {
return err return err
} }
ios.StartSpinner("Fetching label...")
existing, err := findLabelByName(client, owner, name, labelName) existing, err := findLabelByName(client, owner, name, labelName)
ios.StopSpinner()
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("Are you sure you want to delete label %q? (y/N): ", labelName) if !yes {
var confirm string confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete label %q?", labelName))
_, _ = fmt.Scanln(&confirm) if err != nil {
if strings.ToLower(confirm) != "y" { return err
fmt.Println("Aborted") }
return nil if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
} }
ios.StartSpinner("Deleting label...")
_, err = client.DeleteLabel(owner, name, existing.ID) _, err = client.DeleteLabel(owner, name, existing.ID)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to delete label: %w", err) 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 return nil
} }

View file

@ -2,15 +2,14 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings" "strings"
"text/tabwriter"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -45,6 +44,9 @@ var milestoneViewCmd = &cobra.Command{
# View by title # View by title
fgj milestone view "v1.0" fgj milestone view "v1.0"
# Open in browser
fgj milestone view "v1.0" --web
# Output as JSON # Output as JSON
fgj milestone view "v1.0" --json`, fgj milestone view "v1.0" --json`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
@ -91,7 +93,10 @@ var milestoneDeleteCmd = &cobra.Command{
fgj milestone delete "v1.0" fgj milestone delete "v1.0"
# Delete by ID # Delete by ID
fgj milestone delete 1`, fgj milestone delete 1
# Delete without confirmation
fgj milestone delete "v1.0" -y`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runMilestoneDelete, RunE: runMilestoneDelete,
} }
@ -106,24 +111,26 @@ func init() {
milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all") 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().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("repo", "R", "", "Repository in owner/name format")
milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone") milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone")
milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") 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().StringP("repo", "R", "", "Repository in owner/name format")
milestoneEditCmd.Flags().String("title", "", "New title for the milestone") milestoneEditCmd.Flags().String("title", "", "New title for the milestone")
milestoneEditCmd.Flags().StringP("description", "d", "", "New description 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("due", "", "Due date in YYYY-MM-DD format")
milestoneEditCmd.Flags().String("state", "", "New state: open or closed") 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().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. // 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) return fmt.Errorf("invalid state: %s", state)
} }
ios.StartSpinner("Fetching milestones...")
milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{
State: stateType, State: stateType,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to list milestones: %w", err) return fmt.Errorf("failed to list milestones: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(milestones) return outputJSON(cmd, milestones)
} }
if len(milestones) == 0 { 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 return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
_, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n") tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES")
for _, ms := range milestones { for _, ms := range milestones {
due := "" due := ""
if ms.Deadline != nil { if ms.Deadline != nil {
due = ms.Deadline.Format("2006-01-02") due = ms.Deadline.Format("2006-01-02")
} }
_, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n", tp.AddRow(
ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues) fmt.Sprintf("%d", ms.ID),
ms.Title,
string(ms.State),
due,
fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues),
)
} }
_ = w.Flush() return tp.Render()
return nil
} }
func runMilestoneView(cmd *cobra.Command, args []string) error { func runMilestoneView(cmd *cobra.Command, args []string) error {
@ -242,32 +254,45 @@ func runMilestoneView(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0]) ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil { if err != nil {
return err return err
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if web, _ := cmd.Flags().GetBool("web"); web {
return writeJSON(ms) // 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) if wantJSON(cmd) {
fmt.Printf("Title: %s\n", ms.Title) return outputJSON(cmd, ms)
fmt.Printf("State: %s\n", ms.State) }
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 != "" { if ms.Description != "" {
fmt.Printf("Description: %s\n", ms.Description) fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description)
} }
if ms.Deadline != nil { 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.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues)
fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues) fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues)
fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05")) fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY))
if ms.Updated != nil { 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 { 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 return nil
@ -308,16 +333,19 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error {
opt.Deadline = deadline opt.Deadline = deadline
} }
ios.StartSpinner("Creating milestone...")
ms, _, err := client.CreateMilestone(owner, name, opt) ms, _, err := client.CreateMilestone(owner, name, opt)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create milestone: %w", err) return fmt.Errorf("failed to create milestone: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(ms) 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 return nil
} }
@ -340,7 +368,9 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0]) ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil { if err != nil {
return err 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") 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) updated, _, err := client.EditMilestone(owner, name, ms.ID, opt)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to edit milestone: %w", err) return fmt.Errorf("failed to edit milestone: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(updated) 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 return nil
} }
func runMilestoneDelete(cmd *cobra.Command, args []string) error { func runMilestoneDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
owner, name, err := parseRepo(repo) owner, name, err := parseRepo(repo)
if err != nil { if err != nil {
@ -421,17 +455,33 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0]) ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil { if err != nil {
return err 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) _, err = client.DeleteMilestone(owner, name, ms.ID)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to delete milestone: %w", err) 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 return nil
} }

872
cmd/pr.go

File diff suppressed because it is too large Load diff

99
cmd/pr_checks.go Normal file
View file

@ -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 <number>",
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)
}
}

View file

@ -2,14 +2,11 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"strconv"
"strings" "strings"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
var prDiffCmd = &cobra.Command{ var prDiffCmd = &cobra.Command{
@ -46,7 +43,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
nameOnly, _ := cmd.Flags().GetBool("name-only") nameOnly, _ := cmd.Flags().GetBool("name-only")
stat, _ := cmd.Flags().GetBool("stat") stat, _ := cmd.Flags().GetBool("stat")
prNumber, err := strconv.ParseInt(args[0], 10, 64) prNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid pull request number: %w", err) 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", diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
client.Hostname(), owner, name, prNumber) client.Hostname(), owner, name, prNumber)
ios.StartSpinner("Fetching diff...")
diff, err := client.GetRawLog(diffURL) diff, err := client.GetRawLog(diffURL)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to get pull request diff: %w", err) 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) 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) useColor := shouldColorize(colorMode)
if useColor { if useColor {
return printColorizedDiff(diff) return printColorizedDiff(diff)
} }
fmt.Print(diff) fmt.Fprint(ios.Out, diff)
return nil return nil
} }
@ -99,7 +104,7 @@ func shouldColorize(mode string) bool {
case "never": case "never":
return false return false
default: // "auto" 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/") name := strings.TrimPrefix(line, "+++ b/")
if name != "" && !seen[name] { if name != "" && !seen[name] {
seen[name] = true 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. // fileStat holds per-file diff statistics.
type fileStat struct { type fileStat struct {
name string name string
additions int additions int
deletions int deletions int
} }
// printDiffStat parses the diff and prints a diffstat summary. // printDiffStat parses the diff and prints a diffstat summary.
@ -165,10 +170,12 @@ func printDiffStat(diff string) error {
} }
if len(stats) == 0 { if len(stats) == 0 {
fmt.Println("0 files changed") fmt.Fprintln(ios.Out, "0 files changed")
return nil return nil
} }
cs := ios.ColorScheme()
// Find the longest file name for alignment // Find the longest file name for alignment
maxNameLen := 0 maxNameLen := 0
maxChanges := 0 maxChanges := 0
@ -210,44 +217,36 @@ func printDiffStat(diff string) error {
scaledDel = 1 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 { 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 { 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 { if totalDeletions != 1 {
fmt.Print("s") fmt.Fprint(ios.Out, "s")
} }
fmt.Println("(-)") fmt.Fprintln(ios.Out, "(-)")
return nil return nil
} }
// ANSI color codes for diff output. // printColorizedDiff prints the diff with ANSI color codes using ColorScheme.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorCyan = "\033[36m"
colorBold = "\033[1m"
)
// printColorizedDiff prints the diff with ANSI color codes.
func printColorizedDiff(diff string) error { func printColorizedDiff(diff string) error {
cs := ios.ColorScheme()
for _, line := range strings.Split(diff, "\n") { for _, line := range strings.Split(diff, "\n") {
switch { switch {
case strings.HasPrefix(line, "diff --git "): case strings.HasPrefix(line, "diff --git "):
fmt.Println(colorBold + line + colorReset) fmt.Fprintln(ios.Out, cs.Bold(line))
case strings.HasPrefix(line, "index "), case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "--- "),
strings.HasPrefix(line, "+++ "), strings.HasPrefix(line, "+++ "),
@ -256,15 +255,15 @@ func printColorizedDiff(diff string) error {
strings.HasPrefix(line, "similarity index"), strings.HasPrefix(line, "similarity index"),
strings.HasPrefix(line, "rename from"), strings.HasPrefix(line, "rename from"),
strings.HasPrefix(line, "rename to"): strings.HasPrefix(line, "rename to"):
fmt.Println(colorBold + line + colorReset) fmt.Fprintln(ios.Out, cs.Bold(line))
case strings.HasPrefix(line, "@@"): case strings.HasPrefix(line, "@@"):
fmt.Println(colorCyan + line + colorReset) fmt.Fprintln(ios.Out, cs.Cyan(line))
case strings.HasPrefix(line, "+"): case strings.HasPrefix(line, "+"):
fmt.Println(colorGreen + line + colorReset) fmt.Fprintln(ios.Out, cs.Green(line))
case strings.HasPrefix(line, "-"): case strings.HasPrefix(line, "-"):
fmt.Println(colorRed + line + colorReset) fmt.Fprintln(ios.Out, cs.Red(line))
default: default:
fmt.Println(line) fmt.Fprintln(ios.Out, line)
} }
} }
return nil return nil

View file

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "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("repo", "R", "", "Repository in owner/name format")
prCommentCmd.Flags().StringP("body", "b", "", "Comment body") prCommentCmd.Flags().StringP("body", "b", "", "Comment body")
prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") 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().StringP("repo", "R", "", "Repository in owner/name format")
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request") 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().BoolP("comment", "c", false, "Submit as a review comment")
prReviewCmd.Flags().StringP("body", "b", "", "Review body/message") prReviewCmd.Flags().StringP("body", "b", "", "Review body/message")
prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") 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. // 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 { func runPRComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
prNumber, err := strconv.ParseInt(args[0], 10, 64) prNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid pull request number: %w", err) return fmt.Errorf("invalid pull request number: %w", err)
} }
@ -127,19 +126,22 @@ func runPRComment(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Adding comment...")
comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{ comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
Body: body, Body: body,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create comment: %w", err) return fmt.Errorf("failed to create comment: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(comment) return outputJSON(cmd, comment)
} }
fmt.Printf("Comment added to PR #%d\n", prNumber) cs := ios.ColorScheme()
fmt.Printf("View at: %s\n", comment.HTMLURL) 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 return nil
} }
@ -150,7 +152,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
requestChanges, _ := cmd.Flags().GetBool("request-changes") requestChanges, _ := cmd.Flags().GetBool("request-changes")
commentReview, _ := cmd.Flags().GetBool("comment") commentReview, _ := cmd.Flags().GetBool("comment")
prNumber, err := strconv.ParseInt(args[0], 10, 64) prNumber, err := parseIssueArg(args[0])
if err != nil { if err != nil {
return fmt.Errorf("invalid pull request number: %w", err) 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" action = "reviewed with comment"
} }
ios.StartSpinner("Submitting review...")
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{ review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
State: state, State: state,
Body: body, Body: body,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create review: %w", err) return fmt.Errorf("failed to create review: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(review) 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 != "" { if review.HTMLURL != "" {
fmt.Printf("View at: %s\n", review.HTMLURL) fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
} }
return nil return nil

View file

@ -3,14 +3,15 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"text/tabwriter"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -25,39 +26,98 @@ var releaseListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List releases", Short: "List releases",
Long: "List releases in a repository.", 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{ var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>", Use: "view <tag|latest>",
Short: "View a release", Short: "View a release",
Long: "Display detailed information about a release.", Long: "Display detailed information about a release.",
Args: cobra.ExactArgs(1), Example: ` # View a release by tag
RunE: runReleaseView, 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{ var releaseCreateCmd = &cobra.Command{
Use: "create <tag> [files...]", Use: "create <tag> [files...]",
Short: "Create a release", Short: "Create a release",
Long: "Create a new release and optionally upload assets.", Long: "Create a new release and optionally upload assets.",
Args: cobra.MinimumNArgs(1), Example: ` # Create a release
RunE: runReleaseCreate, 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{ var releaseUploadCmd = &cobra.Command{
Use: "upload <tag|latest> <files...>", Use: "upload <tag|latest> <files...>",
Short: "Upload release assets", Short: "Upload release assets",
Long: "Upload assets to an existing release.", Long: "Upload assets to an existing release.",
Args: cobra.MinimumNArgs(2), Example: ` # Upload assets to a release
RunE: runReleaseUpload, 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 <tag>",
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{ var releaseDeleteCmd = &cobra.Command{
Use: "delete <tag|latest>", Use: "delete <tag|latest>",
Short: "Delete a release", Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.", Long: "Delete a release by tag, keeping its Git tag intact.",
Args: cobra.ExactArgs(1), Example: ` # Delete a release by tag
RunE: runReleaseDelete, 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() { func init() {
@ -66,16 +126,18 @@ func init() {
releaseCmd.AddCommand(releaseViewCmd) releaseCmd.AddCommand(releaseViewCmd)
releaseCmd.AddCommand(releaseCreateCmd) releaseCmd.AddCommand(releaseCreateCmd)
releaseCmd.AddCommand(releaseUploadCmd) releaseCmd.AddCommand(releaseUploadCmd)
releaseCmd.AddCommand(releaseDownloadCmd)
releaseCmd.AddCommand(releaseDeleteCmd) releaseCmd.AddCommand(releaseDeleteCmd)
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") 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().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("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") 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().StringP("repo", "R", "", "Repository in owner/name format")
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name") 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().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 { func runReleaseList(cmd *cobra.Command, args []string) error {
@ -131,11 +198,13 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
opts.IsPreRelease = &prereleaseValue opts.IsPreRelease = &prereleaseValue
} }
ios.StartSpinner("Fetching releases...")
var releases []*gitea.Release var releases []*gitea.Release
for page := 1; len(releases) < limit; page++ { for page := 1; len(releases) < limit; page++ {
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize} opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
batch, _, err := client.ListReleases(owner, name, opts) batch, _, err := client.ListReleases(owner, name, opts)
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list releases: %w", err) return fmt.Errorf("failed to list releases: %w", err)
} }
if len(batch) == 0 { if len(batch) == 0 {
@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
} }
releases = append(releases, batch...) releases = append(releases, batch...)
} }
ios.StopSpinner()
if len(releases) > limit { if len(releases) > limit {
releases = releases[:limit] releases = releases[:limit]
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { if wantJSON(cmd) {
return writeJSON(releases) return outputJSON(cmd, releases)
} }
if len(releases) == 0 { 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 return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) isTTY := ios.IsStdoutTTY()
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n") tp := ios.NewTablePrinter()
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
for _, rel := range releases { for _, rel := range releases {
published := releaseTimestamp(rel).Format("2006-01-02") published := text.FormatDate(releaseTimestamp(rel), isTTY)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published) tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
} }
_ = w.Flush() return tp.Render()
return nil
} }
func runReleaseView(cmd *cobra.Command, args []string) error { func runReleaseView(cmd *cobra.Command, args []string) error {
@ -187,17 +256,27 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag) release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil { if err != nil {
ios.StopSpinner()
return err return err
} }
attachments, err := listReleaseAttachments(client, owner, name, release.ID) attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil { if err != nil {
return err 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 { payload := struct {
Release *gitea.Release `json:"release"` Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"` Assets []*gitea.Attachment `json:"assets,omitempty"`
@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
Release: release, Release: release,
Assets: attachments, Assets: attachments,
} }
return writeJSON(payload) return outputJSON(cmd, payload)
} }
fmt.Printf("Release %s\n", release.TagName) if err := ios.StartPager(); err != nil {
fmt.Printf("Title: %s\n", release.Title) fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
fmt.Printf("Type: %s\n", releaseType(release)) }
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 != "" { if release.Target != "" {
fmt.Printf("Target: %s\n", release.Target) fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
} }
if release.Publisher != nil { 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() { 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 != "" { if release.HTMLURL != "" {
fmt.Printf("URL: %s\n", release.HTMLURL) fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
} }
if release.Note != "" { if release.Note != "" {
fmt.Printf("\n%s\n", release.Note) fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
} }
if len(attachments) > 0 { if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments)) fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
for _, asset := range 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 return err
} }
ios.StartSpinner("Creating release...")
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag, TagName: tag,
Target: target, Target: target,
@ -289,24 +377,29 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
IsDraft: draft, IsDraft: draft,
IsPrerelease: prerelease, IsPrerelease: prerelease,
}) })
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create release: %w", err) 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 != "" { 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 { if len(files) == 0 {
return nil return nil
} }
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil { if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
ios.StopSpinner()
return err 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 return nil
} }
@ -332,21 +425,29 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag) release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil { if err != nil {
return err return err
} }
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil { if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
ios.StopSpinner()
return err 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 return nil
} }
func runReleaseDelete(cmd *cobra.Command, args []string) error { func runReleaseDownload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo") repo, _ := cmd.Flags().GetString("repo")
dir, _ := cmd.Flags().GetString("dir")
pattern, _ := cmd.Flags().GetString("pattern")
tag := args[0] tag := args[0]
owner, name, err := parseRepo(repo) owner, name, err := parseRepo(repo)
@ -364,16 +465,120 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag) 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 { if err != nil {
return err return err
} }
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { if len(attachments) == 0 {
return fmt.Errorf("failed to delete release: %w", err) 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 return nil
} }

View file

@ -6,11 +6,11 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -78,17 +78,34 @@ var repoEditCmd = &cobra.Command{
# Change default branch # Change default branch
fgj repo edit --default-branch develop 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) # Edit current repo (auto-detected from git context)
fgj repo edit --public`, fgj repo edit --public`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runRepoEdit, RunE: runRepoEdit,
} }
var repoRenameCmd = &cobra.Command{
Use: "rename <new-name>",
Short: "Rename a repository",
Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name <new-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() { func init() {
rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoCloneCmd) repoCmd.AddCommand(repoCloneCmd)
repoCmd.AddCommand(repoCreateCmd) repoCmd.AddCommand(repoCreateCmd)
repoCmd.AddCommand(repoEditCmd) repoCmd.AddCommand(repoEditCmd)
repoCmd.AddCommand(repoRenameCmd)
repoCmd.AddCommand(repoForkCmd) repoCmd.AddCommand(repoForkCmd)
repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoViewCmd) repoCmd.AddCommand(repoViewCmd)
@ -104,16 +121,25 @@ func init() {
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)") repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private") 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") repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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().StringP("description", "d", "", "Repository description")
repoEditCmd.Flags().String("homepage", "", "Repository home page URL") repoEditCmd.Flags().String("homepage", "", "Repository home page URL")
repoEditCmd.Flags().String("default-branch", "", "Default branch name") repoEditCmd.Flags().String("default-branch", "", "Default branch name")
repoEditCmd.Flags().Bool("private", false, "Make the repository private") repoEditCmd.Flags().Bool("private", false, "Make the repository private")
repoEditCmd.Flags().Bool("public", false, "Make the repository public") 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") 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 { func runRepoView(cmd *cobra.Command, args []string) error {
@ -137,23 +163,36 @@ func runRepoView(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching repository...")
repository, _, err := client.GetRepo(owner, name) repository, _, err := client.GetRepo(owner, name)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to get repository: %w", err) return fmt.Errorf("failed to get repository: %w", err)
} }
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name) if web, _ := cmd.Flags().GetBool("web"); web {
fmt.Printf("Description: %s\n", repository.Description) return ios.OpenInBrowser(repository.HTMLURL)
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) if wantJSON(cmd) {
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch) return outputJSON(cmd, repository)
fmt.Printf("Stars: %d\n", repository.Stars) }
fmt.Printf("Forks: %d\n", repository.Forks)
fmt.Printf("Open Issues: %d\n", repository.OpenIssues) cs := ios.ColorScheme()
fmt.Printf("Private: %v\n", repository.Private) isTTY := ios.IsStdoutTTY()
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")) 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 return nil
} }
@ -169,37 +208,39 @@ func runRepoList(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching repositories...")
user, _, err := client.GetMyUserInfo() user, _, err := client.GetMyUserInfo()
if err != nil { if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get user info: %w", err) return fmt.Errorf("failed to get user info: %w", err)
} }
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{}) repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to list repositories: %w", err) return fmt.Errorf("failed to list repositories: %w", err)
} }
if wantJSON(cmd) {
return outputJSON(cmd, repos)
}
if len(repos) == 0 { if len(repos) == 0 {
fmt.Println("No repositories found") fmt.Fprintln(ios.Out, "No repositories found")
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) tp := ios.NewTablePrinter()
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION")
for _, repo := range repos { for _, repo := range repos {
visibility := "public" visibility := "public"
if repo.Private { if repo.Private {
visibility = "private" visibility = "private"
} }
desc := repo.Description desc := text.Truncate(repo.Description, 50)
if len(desc) > 50 { tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc)
desc = desc[:47] + "..."
}
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
} }
_ = w.Flush() return tp.Render()
return nil
} }
func runRepoClone(cmd *cobra.Command, args []string) error { func runRepoClone(cmd *cobra.Command, args []string) error {
@ -221,7 +262,9 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Fetching repository info...")
repository, _, err := client.GetRepo(owner, name) repository, _, err := client.GetRepo(owner, name)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to get repository: %w", err) return fmt.Errorf("failed to get repository: %w", err)
} }
@ -241,7 +284,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
destination = name 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 // Create parent directory if it doesn't exist
if dir := filepath.Dir(destination); dir != "." { 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 // Execute git clone
gitCmd := exec.Command("git", "clone", cloneURL, destination) gitCmd := exec.Command("git", "clone", cloneURL, destination)
gitCmd.Stdout = os.Stdout gitCmd.Stdout = ios.Out
gitCmd.Stderr = os.Stderr gitCmd.Stderr = ios.ErrOut
gitCmd.Stdin = os.Stdin gitCmd.Stdin = ios.In
if err := gitCmd.Run(); err != nil { if err := gitCmd.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to clone repository: %w", err) 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 return nil
} }
@ -282,14 +329,17 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
return err return err
} }
ios.StartSpinner("Forking repository...")
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{}) fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to fork repository: %w", err) return fmt.Errorf("failed to fork repository: %w", err)
} }
fmt.Printf("Repository forked successfully\n") cs := ios.ColorScheme()
fmt.Printf("View at: %s\n", fork.HTMLURL) fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon())
fmt.Printf("Clone URL: %s\n", fork.CloneURL) fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL)
fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL)
return nil return nil
} }
@ -335,12 +385,14 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
License: license, License: license,
} }
ios.StartSpinner("Creating repository...")
var repo *gitea.Repository var repo *gitea.Repository
if isOrg { if isOrg {
repo, _, err = client.CreateOrgRepo(org, opt) repo, _, err = client.CreateOrgRepo(org, opt)
} else { } else {
repo, _, err = client.CreateRepo(opt) repo, _, err = client.CreateRepo(opt)
} }
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to create repository: %w", err) return fmt.Errorf("failed to create repository: %w", err)
} }
@ -354,7 +406,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
} else { } else {
user, _, userErr := client.GetMyUserInfo() user, _, userErr := client.GetMyUserInfo()
if userErr != nil { 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 homepage = "" // skip EditRepo
} else { } else {
ownerName = user.UserName ownerName = user.UserName
@ -366,23 +418,24 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
Website: &homepage, Website: &homepage,
}) })
if err != nil { 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 team != "" {
if !isOrg { 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 { } else {
_, err = client.AddRepoTeam(org, repo.Name, team) _, err = client.AddRepoTeam(org, repo.Name, team)
if err != nil { 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 { if doClone {
cloneURL := repo.CloneURL cloneURL := repo.CloneURL
@ -391,11 +444,11 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
cloneURL = repo.SSHURL 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 := exec.Command("git", "clone", cloneURL, repo.Name)
gitCmd.Stdout = os.Stdout gitCmd.Stdout = ios.Out
gitCmd.Stderr = os.Stderr gitCmd.Stderr = ios.ErrOut
gitCmd.Stdin = os.Stdin gitCmd.Stdin = ios.In
if err := gitCmd.Run(); err != nil { if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone repository: %w", err) return fmt.Errorf("failed to clone repository: %w", err)
} }
@ -449,6 +502,11 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
opt := gitea.EditRepoOption{} opt := gitea.EditRepoOption{}
changed := false changed := false
if cmd.Flags().Changed("name") {
n, _ := cmd.Flags().GetString("name")
opt.Name = &n
changed = true
}
if cmd.Flags().Changed("description") { if cmd.Flags().Changed("description") {
d, _ := cmd.Flags().GetString("description") d, _ := cmd.Flags().GetString("description")
opt.Description = &d opt.Description = &d
@ -476,36 +534,84 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
} }
if !changed { 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) repository, _, err := client.EditRepo(owner, name, opt)
ios.StopSpinner()
if err != nil { if err != nil {
return fmt.Errorf("failed to edit repository: %w", err) return fmt.Errorf("failed to edit repository: %w", err)
} }
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, repository)
return writeJSON(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 != nil {
if *opt.Private { if *opt.Private {
fmt.Println("Visibility: private") fmt.Fprintln(ios.Out, "Visibility: private")
} else { } else {
fmt.Println("Visibility: public") fmt.Fprintln(ios.Out, "Visibility: public")
} }
} }
if opt.Description != nil { if opt.Description != nil {
fmt.Printf("Description: %s\n", *opt.Description) fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description)
} }
if opt.Website != nil { if opt.Website != nil {
fmt.Printf("Homepage: %s\n", *opt.Website) fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website)
} }
if opt.DefaultBranch != nil { if opt.DefaultBranch != nil {
fmt.Printf("Default branch: %s\n", *opt.DefaultBranch) fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch)
} }
return nil 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
}

View file

@ -3,6 +3,7 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"forgejo.zerova.net/sid/fgj-sid/internal/git" "forgejo.zerova.net/sid/fgj-sid/internal/git"
@ -46,7 +47,7 @@ func initConfig() {
} else { } else {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(ios.ErrOut, err)
os.Exit(1) os.Exit(1)
} }
@ -94,3 +95,26 @@ func getDetectedHost() string {
} }
return host 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)
}

View file

@ -5,29 +5,28 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"text/tabwriter"
"time" "time"
"forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Wiki API response types // Wiki API response types
type wikiPageMeta struct { type wikiPageMeta struct {
Title string `json:"title"` Title string `json:"title"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
SubURL string `json:"sub_url"` SubURL string `json:"sub_url"`
LastCommit *wikiCommit `json:"last_commit"` LastCommit *wikiCommit `json:"last_commit"`
} }
type wikiCommit struct { type wikiCommit struct {
ID string `json:"id"` ID string `json:"id"`
Author *wikiUser `json:"author"` Author *wikiUser `json:"author"`
Committer *wikiUser `json:"committer"` Committer *wikiUser `json:"committer"`
Message string `json:"message"` Message string `json:"message"`
} }
type wikiUser struct { type wikiUser struct {
@ -79,6 +78,9 @@ var wikiViewCmd = &cobra.Command{
Example: ` # View a wiki page Example: ` # View a wiki page
fgj wiki view Home fgj wiki view Home
# Open in browser
fgj wiki view Home --web
# View a wiki page as JSON (includes content) # View a wiki page as JSON (includes content)
fgj wiki view Home --json fgj wiki view Home --json
@ -133,6 +135,9 @@ var wikiDeleteCmd = &cobra.Command{
Example: ` # Delete a wiki page Example: ` # Delete a wiki page
fgj wiki delete "Old Page" fgj wiki delete "Old Page"
# Delete without confirmation
fgj wiki delete "Old Page" -y
# Delete a wiki page from a specific repo # Delete a wiki page from a specific repo
fgj wiki delete "Outdated Guide" -R owner/repo`, fgj wiki delete "Outdated Guide" -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
@ -148,22 +153,24 @@ func init() {
wikiCmd.AddCommand(wikiDeleteCmd) wikiCmd.AddCommand(wikiDeleteCmd)
wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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().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("repo", "R", "", "Repository in owner/name format")
wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content")
wikiCreateCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") 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("repo", "R", "", "Repository in owner/name format")
wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content")
wikiEditCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") 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().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) { 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)) path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(name))
ios.StartSpinner("Fetching wiki pages...")
var pages []wikiPageMeta var pages []wikiPageMeta
if err := client.GetJSON(path, &pages); err != nil { if err := client.GetJSON(path, &pages); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list wiki pages: %w", err) return fmt.Errorf("failed to list wiki pages: %w", err)
} }
ios.StopSpinner()
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, pages)
return writeJSON(pages)
} }
if len(pages) == 0 { if len(pages) == 0 {
fmt.Println("No wiki pages found") fmt.Fprintln(ios.Out, "No wiki pages found")
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) isTTY := ios.IsStdoutTTY()
_, _ = fmt.Fprintf(w, "TITLE\tLAST UPDATED\n") tp := ios.NewTablePrinter()
tp.AddHeader("TITLE", "LAST UPDATED")
for _, p := range pages { for _, p := range pages {
updated := "" updated := ""
if p.LastCommit != nil && p.LastCommit.Committer != nil && p.LastCommit.Committer.Date != "" { 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 { 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 { } else {
updated = p.LastCommit.Committer.Date updated = p.LastCommit.Committer.Date
} }
} }
_, _ = fmt.Fprintf(w, "%s\t%s\n", p.Title, updated) tp.AddRow(p.Title, updated)
} }
_ = w.Flush() return tp.Render()
return nil
} }
func runWikiView(cmd *cobra.Command, args []string) error { 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", path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s",
url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title))
ios.StartSpinner("Fetching wiki page...")
var page wikiPage var page wikiPage
if err := client.GetJSON(path, &page); err != nil { if err := client.GetJSON(path, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get wiki page: %w", err) return fmt.Errorf("failed to get wiki page: %w", err)
} }
ios.StopSpinner()
content, err := base64.StdEncoding.DecodeString(page.ContentBase64) content, err := base64.StdEncoding.DecodeString(page.ContentBase64)
if err != nil { if err != nil {
return fmt.Errorf("failed to decode wiki page content: %w", err) 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") jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag { if jsonFlag {
page.Content = string(content) page.Content = string(content)
return writeJSON(page) return writeJSON(page)
} }
fmt.Printf("# %s\n\n", page.Title) if err := ios.StartPager(); err != nil {
fmt.Print(string(content)) 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 // Ensure trailing newline
if len(content) > 0 && content[len(content)-1] != '\n' { if len(content) > 0 && content[len(content)-1] != '\n' {
fmt.Println() fmt.Fprintln(ios.Out)
} }
return nil return nil
@ -288,17 +311,20 @@ func runWikiCreate(cmd *cobra.Command, args []string) error {
ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)),
} }
ios.StartSpinner("Creating wiki page...")
var page wikiPage var page wikiPage
if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil { if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to create wiki page: %w", err) return fmt.Errorf("failed to create wiki page: %w", err)
} }
ios.StopSpinner()
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, page)
return writeJSON(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 return nil
} }
@ -326,35 +352,54 @@ func runWikiEdit(cmd *cobra.Command, args []string) error {
ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)),
} }
ios.StartSpinner("Updating wiki page...")
var page wikiPage var page wikiPage
if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil { if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to update wiki page: %w", err) return fmt.Errorf("failed to update wiki page: %w", err)
} }
ios.StopSpinner()
jsonFlag, _ := cmd.Flags().GetBool("json") if wantJSON(cmd) {
if jsonFlag { return outputJSON(cmd, page)
return writeJSON(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 return nil
} }
func runWikiDelete(cmd *cobra.Command, args []string) error { func runWikiDelete(cmd *cobra.Command, args []string) error {
title := args[0] title := args[0]
yes, _ := cmd.Flags().GetBool("yes")
client, owner, name, err := newWikiClient(cmd) client, owner, name, err := newWikiClient(cmd)
if err != nil { if err != nil {
return err 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", path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s",
url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) 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 { if _, err := client.DoJSON(http.MethodDelete, path, nil, nil); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to delete wiki page: %w", err) 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 return nil
} }

6
go.mod
View file

@ -1,6 +1,6 @@
module forgejo.zerova.net/sid/fgj-sid module forgejo.zerova.net/sid/fgj-sid
go 1.23.0 go 1.24.0
require ( require (
code.gitea.io/sdk/gitea v0.22.1 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/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.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/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@ -34,7 +36,7 @@ require (
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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 golang.org/x/text v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )

6
go.sum
View file

@ -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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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.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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.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 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

View file

@ -6,11 +6,16 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/config" "forgejo.zerova.net/sid/fgj-sid/internal/config"
) )
var sharedHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
type Client struct { type Client struct {
*gitea.Client *gitea.Client
hostname string hostname string
@ -63,8 +68,7 @@ func (c *Client) GetJSON(path string, result any) error {
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
httpClient := &http.Client{} resp, err := sharedHTTPClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to perform request: %w", err) 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 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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{ return &APIError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Body: string(body), 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") req.Header.Set("Content-Type", "application/json")
} }
httpClient := &http.Client{} resp, err := sharedHTTPClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to perform request: %w", err) 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 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body) 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{ return resp.StatusCode, &APIError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Body: string(bodyBytes), Body: string(bodyBytes),
@ -154,6 +163,40 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
return resp.StatusCode, nil 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 // GetRawLog performs a GET request and returns the raw response body as string
func (c *Client) GetRawLog(url string) (string, error) { func (c *Client) GetRawLog(url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil) 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) req.Header.Set("Authorization", "token "+c.token)
} }
httpClient := &http.Client{} resp, err := sharedHTTPClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to perform request: %w", err) 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 { 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{ return "", &APIError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Body: string(body), Body: string(body),

View file

@ -114,6 +114,48 @@ func parseGitConfig(configPath string) (string, error) {
return "", fmt.Errorf("no origin remote found in git config") 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: // parseRemoteURL extracts owner/name/hostname from various git URL formats:
// - https://codeberg.org/owner/name.git // - https://codeberg.org/owner/name.git
// - git@codeberg.org:owner/name.git // - git@codeberg.org:owner/name.git

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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()
}

70
internal/text/text.go Normal file
View file

@ -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)
}

View file

@ -9,6 +9,7 @@ import (
func main() { func main() {
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
err = cmd.ContextualError(err)
if cmd.JSONErrors() { if cmd.JSONErrors() {
cmd.WriteJSONError(err) cmd.WriteJSONError(err)
} else { } else {