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

Add PR checks command, iostreams/text packages for colored table output,
top-level run/workflow aliases matching gh CLI structure. Enhance actions,
issues, PRs, releases, repos, labels, milestones, and wiki commands with
improved flags, JSON output, and error handling.
This commit is contained in:
sid 2026-03-23 11:42:44 -06:00
parent 7c0dcc8696
commit 113505de95
29 changed files with 3131 additions and 542 deletions

View file

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