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