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

View file

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

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

View file

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

View file

@ -3,7 +3,8 @@ package cmd
import (
"encoding/json"
"errors"
"os"
"fmt"
"strings"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
)
@ -40,6 +41,45 @@ func NewAPIError(status int, message string) *CLIError {
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
}
// ContextualError wraps common errors with helpful hints.
func ContextualError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
// Check for API errors with status codes
var apiErr *api.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == 401 || apiErr.StatusCode == 403:
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
case apiErr.StatusCode == 404:
return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err)
}
return err
}
// Check for network/connection errors
switch {
case strings.Contains(msg, "no such host"):
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
case strings.Contains(msg, "connection refused"):
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
}
// Check for string-based status code patterns (from wrapped errors)
switch {
case strings.Contains(msg, "401") || strings.Contains(msg, "403"):
if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") {
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
}
}
return err
}
// writeJSONError writes a structured JSON error to stderr.
// It attempts to extract structured info from known error types.
// WriteJSONError writes a structured JSON error to stderr.
@ -70,7 +110,7 @@ func WriteJSONError(err error) {
}
}
enc := json.NewEncoder(os.Stderr)
enc := json.NewEncoder(ios.ErrOut)
enc.SetIndent("", " ")
_ = enc.Encode(cliErr)
}

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 (
"fmt"
"net/http"
"os"
"strconv"
"strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra"
)
@ -24,46 +22,114 @@ var issueListCmd = &cobra.Command{
Use: "list [flags]",
Short: "List issues",
Long: "List issues in a repository.",
RunE: runIssueList,
Example: ` # List open issues
fgj issue list
# List closed issues for a specific repo
fgj issue list -s closed -R owner/repo
# Output as JSON
fgj issue list --json`,
RunE: runIssueList,
}
var issueViewCmd = &cobra.Command{
Use: "view <number>",
Short: "View an issue",
Long: "Display detailed information about an issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueView,
Example: ` # View issue #42
fgj issue view 42
# View using URL
fgj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser
fgj issue view 42 --web
# View an issue from a specific repo as JSON
fgj issue view 42 -R owner/repo --json`,
Args: cobra.ExactArgs(1),
RunE: runIssueView,
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an issue",
Long: "Create a new issue.",
RunE: runIssueCreate,
Example: ` # Create an issue with a title
fgj issue create -t "Fix login bug"
# Create an issue with title, body, and labels
fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
RunE: runIssueCreate,
}
var issueCommentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
Example: ` # Add a comment to issue #42
fgj issue comment 42 -b "This is fixed in the latest release"
# Comment on an issue in a specific repo
fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
}
var issueCloseCmd = &cobra.Command{
Use: "close <number>",
Short: "Close an issue",
Long: "Close an existing issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
Example: ` # Close issue #42
fgj issue close 42
# Close with a comment
fgj issue close 42 -c "Fixed in commit abc1234"`,
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
var issueReopenCmd = &cobra.Command{
Use: "reopen <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{
Use: "edit <number>",
Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.",
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
Example: ` # Update the title of issue #42
fgj issue edit 42 -t "Updated title"
# Reopen a closed issue
fgj issue edit 42 -s open
# Add and remove labels
fgj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency
fgj issue edit 42 --add-dependency 10`,
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
}
func init() {
@ -73,19 +139,31 @@ func init() {
issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueReopenCmd)
issueCmd.AddCommand(issueDeleteCmd)
issueCmd.AddCommand(issueEditCmd)
issueReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results")
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username")
issueListCmd.Flags().String("author", "", "Filter by author username")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
addJSONFlags(issueListCmd, "Output issues as JSON")
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
addJSONFlags(issueViewCmd, "Output issue as JSON")
issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue")
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)")
issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.")
issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue")
issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body")
@ -93,6 +171,9 @@ func init() {
issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue")
@ -106,6 +187,11 @@ func init() {
func runIssueList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
state, _ := cmd.Flags().GetString("state")
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
author, _ := cmd.Flags().GetString("author")
labels, _ := cmd.Flags().GetStringSlice("label")
search, _ := cmd.Flags().GetString("search")
owner, name, err := parseRepo(repo)
if err != nil {
@ -134,9 +220,16 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state)
}
ios.StartSpinner("Fetching issues...")
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
ListOptions: gitea.ListOptions{PageSize: limit},
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
@ -148,28 +241,26 @@ func runIssueList(cmd *cobra.Command, args []string) error {
}
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(nonPRIssues)
if wantJSON(cmd) {
return outputJSON(cmd, nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
tp := ios.NewTablePrinter()
tp.AddHeader("NUMBER", "TITLE", "STATE")
for _, issue := range nonPRIssues {
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State))
}
_ = w.Flush()
return nil
return tp.Render()
}
func runIssueView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -189,8 +280,10 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching issue...")
issue, _, err := client.GetIssue(owner, name, issueNumber)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get issue: %w", err)
}
@ -199,8 +292,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
if err != nil {
comments = nil
}
ios.StopSpinner()
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(issue.HTMLURL)
}
if wantJSON(cmd) {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
@ -208,26 +306,34 @@ func runIssueView(cmd *cobra.Command, args []string) error {
Issue: issue,
Comments: comments,
}
return writeJSON(payload)
return outputJSON(cmd, payload)
}
fmt.Printf("Issue #%d\n", issue.Index)
fmt.Printf("Title: %s\n", issue.Title)
fmt.Printf("State: %s\n", issue.State)
fmt.Printf("Author: %s\n", issue.Poster.UserName)
fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05"))
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title))
fmt.Fprintf(ios.Out, "State: %s\n", issue.State)
fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY))
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY))
if issue.Body != "" {
fmt.Printf("\n%s\n", issue.Body)
fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
}
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",
fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n",
comment.Poster.FullName,
comment.Poster.UserName,
comment.Created.Format("2006-01-02 15:04:05"),
text.FormatDate(comment.Created, isTTY),
comment.Body)
}
}
@ -240,14 +346,28 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
labelNames, _ := cmd.Flags().GetStringSlice("label")
assignees, _ := cmd.Flags().GetStringSlice("assignee")
milestoneName, _ := cmd.Flags().GetString("milestone")
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("title is required")
// Interactive mode: prompt for missing fields when TTY
if title == "" && ios.IsStdinTTY() {
title, err = promptLine("Title: ")
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("title is required")
}
if body == "" {
body, _ = promptLine("Body (optional): ")
}
} else if title == "" {
return fmt.Errorf("title is required (use -t flag)")
}
cfg, err := config.Load()
@ -268,17 +388,56 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
}
}
// Resolve @me in assignees
resolvedAssignees := make([]string, 0, len(assignees))
for _, assignee := range assignees {
if assignee == "@me" {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
return fmt.Errorf("failed to get current user info: %w", userErr)
}
resolvedAssignees = append(resolvedAssignees, user.UserName)
} else {
resolvedAssignees = append(resolvedAssignees, assignee)
}
}
// Resolve milestone name to ID
var milestoneID int64
if milestoneName != "" {
milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{})
if msErr != nil {
return fmt.Errorf("failed to list milestones: %w", msErr)
}
found := false
for _, ms := range milestones {
if ms.Title == milestoneName {
milestoneID = ms.ID
found = true
break
}
}
if !found {
return fmt.Errorf("milestone not found: %s", milestoneName)
}
}
ios.StartSpinner("Creating issue...")
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
Title: title,
Body: body,
Labels: labelIDs,
Title: title,
Body: body,
Labels: labelIDs,
Assignees: resolvedAssignees,
Milestone: milestoneID,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
fmt.Printf("Issue created: #%d\n", issue.Index)
fmt.Printf("View at: %s\n", issue.HTMLURL)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index)
fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL)
return nil
}
@ -286,7 +445,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
func runIssueComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
body, _ := cmd.Flags().GetString("body")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -310,15 +469,18 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Adding comment...")
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: body,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
fmt.Printf("Comment added to issue #%d\n", issueNumber)
fmt.Printf("View at: %s\n", comment.HTMLURL)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber)
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
return nil
}
@ -326,7 +488,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
func runIssueClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -347,23 +509,28 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
}
if commentBody != "" {
ios.StartSpinner("Adding comment...")
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: commentBody,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
}
ios.StartSpinner("Closing issue...")
stateClosed := gitea.StateClosed
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateClosed,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
fmt.Printf("Issue #%d closed\n", issueNumber)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber)
return nil
}
@ -378,7 +545,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency")
removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -425,9 +592,12 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
}
}
ios.StartSpinner("Updating issue...")
if title != "" || body != "" || stateStr != "" {
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to edit issue: %w", err)
}
}
@ -435,12 +605,14 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if len(addLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
Labels: labelIDs,
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add labels: %w", err)
}
}
@ -448,16 +620,20 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if len(removeLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
}
}
}
ios.StopSpinner()
for _, depNumber := range addDeps {
depIssue, _, err := client.GetIssue(owner, name, depNumber)
if err != nil {
@ -469,7 +645,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err)
}
fmt.Printf("Added dependency: #%d depends on #%d\n", issueNumber, depNumber)
fmt.Fprintf(ios.Out, "Added dependency: #%d depends on #%d\n", issueNumber, depNumber)
}
for _, depNumber := range removeDeps {
@ -483,10 +659,96 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err)
}
fmt.Printf("Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber)
fmt.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber)
}
fmt.Printf("Issue #%d updated\n", issueNumber)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber)
return nil
}
func runIssueDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
if !yes {
confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name))
if confirmErr != nil {
return confirmErr
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting issue...")
_, err = client.DeleteIssue(owner, name, issueNumber)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name)
return nil
}
func runIssueReopen(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Reopening issue...")
stateOpen := gitea.StateOpen
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateOpen,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to reopen issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), issueNumber)
return nil
}

View file

@ -2,11 +2,154 @@ package cmd
import (
"encoding/json"
"os"
"fmt"
"strings"
"github.com/itchyny/gojq"
"github.com/spf13/cobra"
)
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
// --json is an optional-value string flag:
// - --json (no value) → output all fields as JSON
// - --json title,state → output only those fields (gh-compatible)
//
// --json-fields is kept as a backwards-compatible alias.
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
f := cmd.Flags()
f.String("json", "", jsonDesc)
f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value
f.String("json-fields", "", "Comma-separated list of JSON fields to include")
f.String("jq", "", "Filter JSON output using a jq expression")
}
// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq.
func wantJSON(cmd *cobra.Command) bool {
if j, _ := cmd.Flags().GetString("json"); j != "" {
return true
}
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
return true
}
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
return true
}
return false
}
// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags.
func outputJSON(cmd *cobra.Command, value any) error {
jsonVal, _ := cmd.Flags().GetString("json")
jsonFields, _ := cmd.Flags().GetString("json-fields")
jqExpr, _ := cmd.Flags().GetString("jq")
fields := ""
jsonVal = strings.TrimSpace(jsonVal)
if jsonVal != "" {
fields = jsonVal
} else if jsonFields != "" {
fields = jsonFields
}
return writeJSONFiltered(value, fields, jqExpr)
}
// writeJSON writes a value as pretty-printed JSON to ios.Out.
func writeJSON(value any) error {
enc := json.NewEncoder(os.Stdout)
enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ")
return enc.Encode(value)
}
// writeJSONFiltered writes a value as JSON, optionally selecting specific fields
// and/or applying a jq expression. If fields is empty and jqExpr is empty, it
// writes the full value.
func writeJSONFiltered(value any, fields string, jqExpr string) error {
// If no filtering, just write the full JSON.
if fields == "" && jqExpr == "" {
return writeJSON(value)
}
// Convert value to a generic interface via JSON round-trip so we can
// manipulate it with maps/slices.
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshaling JSON: %w", err)
}
var data any
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("unmarshaling JSON: %w", err)
}
// Apply field selection if specified.
if fields != "" {
fieldList := strings.Split(fields, ",")
for i, f := range fieldList {
fieldList[i] = strings.TrimSpace(f)
}
data = selectFields(data, fieldList)
}
// Apply jq expression if specified.
if jqExpr != "" {
return applyJQ(data, jqExpr)
}
return writeJSON(data)
}
// selectFields filters a JSON value to only include the specified fields.
// Works on both single objects and arrays of objects.
func selectFields(data any, fields []string) any {
switch v := data.(type) {
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = selectFields(item, fields)
}
return result
case map[string]any:
result := make(map[string]any)
for _, field := range fields {
if val, ok := v[field]; ok {
result[field] = val
}
}
return result
default:
return data
}
}
// applyJQ applies a jq expression to data and writes each output value.
func applyJQ(data any, expr string) error {
query, err := gojq.Parse(expr)
if err != nil {
return fmt.Errorf("invalid jq expression: %w", err)
}
iter := query.Run(data)
enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ")
for {
v, ok := iter.Next()
if !ok {
break
}
if err, isErr := v.(error); isErr {
return fmt.Errorf("jq error: %w", err)
}
// For string values, print raw (no JSON encoding) to match jq behavior.
if s, ok := v.(string); ok {
fmt.Fprintln(ios.Out, s)
} else {
if err := enc.Encode(v); err != nil {
return err
}
}
}
return nil
}

View file

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

View file

@ -2,15 +2,14 @@ package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra"
)
@ -45,6 +44,9 @@ var milestoneViewCmd = &cobra.Command{
# View by title
fgj milestone view "v1.0"
# Open in browser
fgj milestone view "v1.0" --web
# Output as JSON
fgj milestone view "v1.0" --json`,
Args: cobra.ExactArgs(1),
@ -91,7 +93,10 @@ var milestoneDeleteCmd = &cobra.Command{
fgj milestone delete "v1.0"
# Delete by ID
fgj milestone delete 1`,
fgj milestone delete 1
# Delete without confirmation
fgj milestone delete "v1.0" -y`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneDelete,
}
@ -106,24 +111,26 @@ func init() {
milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all")
milestoneListCmd.Flags().Bool("json", false, "Output milestones as JSON")
addJSONFlags(milestoneListCmd, "Output milestones as JSON")
milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneViewCmd.Flags().Bool("json", false, "Output milestone as JSON")
addJSONFlags(milestoneViewCmd, "Output milestone as JSON")
milestoneViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
milestoneCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone")
milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format")
milestoneCreateCmd.Flags().Bool("json", false, "Output created milestone as JSON")
addJSONFlags(milestoneCreateCmd, "Output created milestone as JSON")
milestoneEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneEditCmd.Flags().String("title", "", "New title for the milestone")
milestoneEditCmd.Flags().StringP("description", "d", "", "New description for the milestone")
milestoneEditCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format")
milestoneEditCmd.Flags().String("state", "", "New state: open or closed")
milestoneEditCmd.Flags().Bool("json", false, "Output updated milestone as JSON")
addJSONFlags(milestoneEditCmd, "Output updated milestone as JSON")
milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
// resolveMilestone resolves a title-or-id argument to a milestone.
@ -193,35 +200,40 @@ func runMilestoneList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state)
}
ios.StartSpinner("Fetching milestones...")
milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{
State: stateType,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list milestones: %w", err)
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(milestones)
if wantJSON(cmd) {
return outputJSON(cmd, milestones)
}
if len(milestones) == 0 {
fmt.Printf("No %s milestones in %s/%s\n", state, owner, name)
fmt.Fprintf(ios.Out, "No %s milestones in %s/%s\n", state, owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n")
tp := ios.NewTablePrinter()
tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES")
for _, ms := range milestones {
due := ""
if ms.Deadline != nil {
due = ms.Deadline.Format("2006-01-02")
}
_, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n",
ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues)
tp.AddRow(
fmt.Sprintf("%d", ms.ID),
ms.Title,
string(ms.State),
due,
fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues),
)
}
_ = w.Flush()
return nil
return tp.Render()
}
func runMilestoneView(cmd *cobra.Command, args []string) error {
@ -242,32 +254,45 @@ func runMilestoneView(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(ms)
if web, _ := cmd.Flags().GetBool("web"); web {
// Milestones don't have HTMLURL in the API, construct it
cfg2, _ := config.Load()
host, _ := cfg2.GetHost("", getDetectedHost())
url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID)
return ios.OpenInBrowser(url)
}
fmt.Printf("ID: %d\n", ms.ID)
fmt.Printf("Title: %s\n", ms.Title)
fmt.Printf("State: %s\n", ms.State)
if wantJSON(cmd) {
return outputJSON(cmd, ms)
}
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "ID: %d\n", ms.ID)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(ms.Title))
fmt.Fprintf(ios.Out, "State: %s\n", ms.State)
if ms.Description != "" {
fmt.Printf("Description: %s\n", ms.Description)
fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description)
}
if ms.Deadline != nil {
fmt.Printf("Due Date: %s\n", ms.Deadline.Format("2006-01-02"))
fmt.Fprintf(ios.Out, "Due Date: %s\n", ms.Deadline.Format("2006-01-02"))
}
fmt.Printf("Open Issues: %d\n", ms.OpenIssues)
fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues)
fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05"))
fmt.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues)
fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY))
if ms.Updated != nil {
fmt.Printf("Updated: %s\n", ms.Updated.Format("2006-01-02 15:04:05"))
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY))
}
if ms.Closed != nil {
fmt.Printf("Closed: %s\n", ms.Closed.Format("2006-01-02 15:04:05"))
fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY))
}
return nil
@ -308,16 +333,19 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error {
opt.Deadline = deadline
}
ios.StartSpinner("Creating milestone...")
ms, _, err := client.CreateMilestone(owner, name, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create milestone: %w", err)
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(ms)
if wantJSON(cmd) {
return outputJSON(cmd, ms)
}
fmt.Printf("Milestone created: %s\n", ms.Title)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title)
return nil
}
@ -340,7 +368,9 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
@ -389,22 +419,26 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no changes specified; use flags like --title, --description, --due, or --state")
}
ios.StartSpinner("Updating milestone...")
updated, _, err := client.EditMilestone(owner, name, ms.ID, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to edit milestone: %w", err)
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(updated)
if wantJSON(cmd) {
return outputJSON(cmd, updated)
}
fmt.Printf("Milestone updated: %s\n", updated.Title)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), updated.Title)
return nil
}
func runMilestoneDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
owner, name, err := parseRepo(repo)
if err != nil {
@ -421,17 +455,33 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete milestone %q?", ms.Title))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting milestone...")
_, err = client.DeleteMilestone(owner, name, ms.ID)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete milestone: %w", err)
}
fmt.Printf("Milestone deleted: %s\n", ms.Title)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone deleted: %s\n", cs.SuccessIcon(), ms.Title)
return nil
}

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

View file

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

View file

@ -3,14 +3,15 @@ package cmd
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra"
)
@ -25,39 +26,98 @@ var releaseListCmd = &cobra.Command{
Use: "list",
Short: "List releases",
Long: "List releases in a repository.",
RunE: runReleaseList,
Example: ` # List releases
fgj release list
# List only draft releases
fgj release list --draft
# Output as JSON with a custom limit
fgj release list --json --limit 10`,
RunE: runReleaseList,
}
var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>",
Short: "View a release",
Long: "Display detailed information about a release.",
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
Example: ` # View a release by tag
fgj release view v1.0.0
# View the latest release
fgj release view latest
# Open in browser
fgj release view v1.0.0 --web
# Output as JSON
fgj release view v1.0.0 --json`,
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
}
var releaseCreateCmd = &cobra.Command{
Use: "create <tag> [files...]",
Short: "Create a release",
Long: "Create a new release and optionally upload assets.",
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
Example: ` # Create a release
fgj release create v1.0.0
# Create with title and notes
fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
# Create a draft prerelease with assets
fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
# Create from release notes file
fgj release create v1.0.0 -F CHANGELOG.md`,
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
}
var releaseUploadCmd = &cobra.Command{
Use: "upload <tag|latest> <files...>",
Short: "Upload release assets",
Long: "Upload assets to an existing release.",
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
Example: ` # Upload assets to a release
fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
# Upload to the latest release, overwriting existing assets
fgj release upload latest build/output.zip --clobber`,
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDownloadCmd = &cobra.Command{
Use: "download <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{
Use: "delete <tag|latest>",
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
Example: ` # Delete a release by tag
fgj release delete v1.0.0
# Delete the latest release
fgj release delete latest
# Delete without confirmation
fgj release delete v1.0.0 -y`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
}
func init() {
@ -66,16 +126,18 @@ func init() {
releaseCmd.AddCommand(releaseViewCmd)
releaseCmd.AddCommand(releaseCreateCmd)
releaseCmd.AddCommand(releaseUploadCmd)
releaseCmd.AddCommand(releaseDownloadCmd)
releaseCmd.AddCommand(releaseDeleteCmd)
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
addJSONFlags(releaseListCmd, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
addJSONFlags(releaseViewCmd, "Output release as JSON")
releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
@ -88,7 +150,12 @@ func init() {
releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name")
releaseDownloadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDownloadCmd.Flags().StringP("dir", "D", ".", "Directory to download files into")
releaseDownloadCmd.Flags().StringP("pattern", "p", "", "Glob pattern to filter assets by name")
releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func runReleaseList(cmd *cobra.Command, args []string) error {
@ -131,11 +198,13 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
opts.IsPreRelease = &prereleaseValue
}
ios.StartSpinner("Fetching releases...")
var releases []*gitea.Release
for page := 1; len(releases) < limit; page++ {
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
batch, _, err := client.ListReleases(owner, name, opts)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list releases: %w", err)
}
if len(batch) == 0 {
@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
}
releases = append(releases, batch...)
}
ios.StopSpinner()
if len(releases) > limit {
releases = releases[:limit]
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(releases)
if wantJSON(cmd) {
return outputJSON(cmd, releases)
}
if len(releases) == 0 {
fmt.Printf("No releases in %s/%s\n", owner, name)
fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
isTTY := ios.IsStdoutTTY()
tp := ios.NewTablePrinter()
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
for _, rel := range releases {
published := releaseTimestamp(rel).Format("2006-01-02")
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
published := text.FormatDate(releaseTimestamp(rel), isTTY)
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
}
_ = w.Flush()
return nil
return tp.Render()
}
func runReleaseView(cmd *cobra.Command, args []string) error {
@ -187,17 +256,27 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
if web, _ := cmd.Flags().GetBool("web"); web {
if release.HTMLURL != "" {
return ios.OpenInBrowser(release.HTMLURL)
}
return fmt.Errorf("release has no HTML URL")
}
if wantJSON(cmd) {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
Release: release,
Assets: attachments,
}
return writeJSON(payload)
return outputJSON(cmd, payload)
}
fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release))
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Release %s\n", cs.Bold(release.TagName))
fmt.Fprintf(ios.Out, "Title: %s\n", release.Title)
fmt.Fprintf(ios.Out, "Type: %s\n", releaseType(release))
if release.Target != "" {
fmt.Printf("Target: %s\n", release.Target)
fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
}
if release.Publisher != nil {
fmt.Printf("Author: %s\n", release.Publisher.UserName)
fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName)
}
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY))
if !release.PublishedAt.IsZero() {
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY))
}
if release.HTMLURL != "" {
fmt.Printf("URL: %s\n", release.HTMLURL)
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
}
if release.Note != "" {
fmt.Printf("\n%s\n", release.Note)
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
}
if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments))
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
for _, asset := range attachments {
fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
}
}
@ -281,6 +368,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Creating release...")
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag,
Target: target,
@ -289,24 +377,29 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
IsDraft: draft,
IsPrerelease: prerelease,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
fmt.Printf("Release created: %s\n", release.TagName)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName)
if release.HTMLURL != "" {
fmt.Printf("View at: %s\n", release.HTMLURL)
fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL)
}
if len(files) == 0 {
return nil
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
fmt.Printf("Uploaded %d asset(s)\n", len(files))
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
return nil
}
@ -332,21 +425,29 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
fmt.Printf("Uploaded %d asset(s)\n", len(files))
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
return nil
}
func runReleaseDelete(cmd *cobra.Command, args []string) error {
func runReleaseDownload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
dir, _ := cmd.Flags().GetString("dir")
pattern, _ := cmd.Flags().GetString("pattern")
tag := args[0]
owner, name, err := parseRepo(repo)
@ -364,16 +465,120 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
return fmt.Errorf("failed to delete release: %w", err)
if len(attachments) == 0 {
fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName)
return nil
}
fmt.Printf("Release %s deleted\n", release.TagName)
// Filter by pattern if provided
var toDownload []*gitea.Attachment
for _, a := range attachments {
if pattern != "" {
matched, matchErr := path.Match(pattern, a.Name)
if matchErr != nil {
return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr)
}
if !matched {
continue
}
}
toDownload = append(toDownload, a)
}
if len(toDownload) == 0 {
fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName)
return nil
}
// Ensure download directory exists
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
for _, a := range toDownload {
destPath := filepath.Join(dir, a.Name)
f, createErr := os.Create(destPath)
if createErr != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, createErr)
}
dlErr := client.DownloadFile(a.DownloadURL, f)
closeErr := f.Close()
if dlErr != nil {
return fmt.Errorf("failed to download %s: %w", a.Name, dlErr)
}
if closeErr != nil {
return fmt.Errorf("failed to close %s: %w", destPath, closeErr)
}
fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir)
return nil
}
func runReleaseDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete release %s?", release.TagName))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting release...")
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to delete release: %w", err)
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release %s deleted\n", cs.SuccessIcon(), release.TagName)
return nil
}

View file

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

View file

@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"forgejo.zerova.net/sid/fgj-sid/internal/git"
@ -46,7 +47,7 @@ func initConfig() {
} else {
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(ios.ErrOut, err)
os.Exit(1)
}
@ -94,3 +95,26 @@ func getDetectedHost() string {
}
return host
}
// promptLine prints a prompt to stderr and reads a line from stdin.
func promptLine(prompt string) (string, error) {
fmt.Fprint(ios.ErrOut, prompt)
var buf [1024]byte
n, err := ios.In.Read(buf[:])
if err != nil {
return "", fmt.Errorf("reading input: %w", err)
}
return strings.TrimSpace(string(buf[:n])), nil
}
// parseIssueArg parses an issue/PR number from various formats:
// "123", "#123", "https://host/owner/repo/pulls/123", "https://host/owner/repo/issues/123"
func parseIssueArg(arg string) (int64, error) {
arg = strings.TrimPrefix(arg, "#")
// Try URL format
if strings.HasPrefix(arg, "http") {
parts := strings.Split(strings.TrimRight(arg, "/"), "/")
arg = parts[len(parts)-1]
}
return strconv.ParseInt(arg, 10, 64)
}

View file

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