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