package cmd import ( "fmt" "os" "path" "path/filepath" "strings" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fj/internal/text" "github.com/spf13/cobra" ) var releaseCmd = &cobra.Command{ Use: "release", Aliases: []string{"releases"}, Short: "Manage releases", Long: "Create, view, list, upload assets, and delete releases.", } var releaseListCmd = &cobra.Command{ Use: "list", Short: "List releases", Long: "List releases in a repository.", Example: ` # List releases fj release list # List only draft releases fj release list --draft # Output as JSON with a custom limit fj release list --json --limit 10`, RunE: runReleaseList, } var releaseViewCmd = &cobra.Command{ Use: "view ", Short: "View a release", Long: "Display detailed information about a release.", Example: ` # View a release by tag fj release view v1.0.0 # View the latest release fj release view latest # Open in browser fj release view v1.0.0 --web # Output as JSON fj release view v1.0.0 --json`, Args: cobra.ExactArgs(1), RunE: runReleaseView, } var releaseCreateCmd = &cobra.Command{ Use: "create [files...]", Short: "Create a release", Long: "Create a new release and optionally upload assets.", Example: ` # Create a release fj release create v1.0.0 # Create with title and notes fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements" # Create a draft prerelease with assets fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz # Create from release notes file fj release create v1.0.0 -F CHANGELOG.md`, Args: cobra.MinimumNArgs(1), RunE: runReleaseCreate, } var releaseUploadCmd = &cobra.Command{ Use: "upload ", Short: "Upload release assets", Long: "Upload assets to an existing release.", Example: ` # Upload assets to a release fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64 # Upload to the latest release, overwriting existing assets fj release upload latest build/output.zip --clobber`, Args: cobra.MinimumNArgs(2), RunE: runReleaseUpload, } var releaseDownloadCmd = &cobra.Command{ Use: "download ", Short: "Download release assets", Long: "Download assets from a release.", Example: ` # Download all assets from a release fj release download v1.0.0 # Download to a specific directory fj release download v1.0.0 -D ./downloads # Download a specific asset by name pattern fj release download v1.0.0 -p "*.tar.gz"`, Args: cobra.ExactArgs(1), RunE: runReleaseDownload, } var releaseDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a release", Long: "Delete a release by tag, keeping its Git tag intact.", Example: ` # Delete a release by tag fj release delete v1.0.0 # Delete the latest release fj release delete latest # Delete without confirmation fj release delete v1.0.0 -y`, Args: cobra.ExactArgs(1), RunE: runReleaseDelete, } func init() { rootCmd.AddCommand(releaseCmd) releaseCmd.AddCommand(releaseListCmd) 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") addJSONFlags(releaseListCmd, "Output releases as JSON") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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)") releaseCreateCmd.Flags().StringP("notes", "n", "", "Release notes") releaseCreateCmd.Flags().StringP("notes-file", "F", "", "Read release notes from file") releaseCreateCmd.Flags().Bool("draft", false, "Create a draft release") releaseCreateCmd.Flags().Bool("prerelease", false, "Mark the release as prerelease") releaseCreateCmd.Flags().String("target", "", "Target commitish (branch or SHA)") 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 { repo, _ := cmd.Flags().GetString("repo") limit, _ := cmd.Flags().GetInt("limit") draftValue, _ := cmd.Flags().GetBool("draft") prereleaseValue, _ := cmd.Flags().GetBool("prerelease") draftSet := cmd.Flags().Changed("draft") prereleaseSet := cmd.Flags().Changed("prerelease") if limit <= 0 { return fmt.Errorf("limit must be greater than 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(), getCwd()) if err != nil { return err } pageSize := limit if pageSize > 50 { pageSize = 50 } opts := gitea.ListReleasesOptions{} if draftSet { opts.IsDraft = &draftValue } if prereleaseSet { 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 { break } releases = append(releases, batch...) } ios.StopSpinner() if len(releases) > limit { releases = releases[:limit] } if wantJSON(cmd) { return outputJSON(cmd, releases) } if len(releases) == 0 { fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name) return nil } isTTY := ios.IsStdoutTTY() tp := ios.NewTablePrinter() tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED") for _, rel := range releases { published := text.FormatDate(releaseTimestamp(rel), isTTY) tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published) } return tp.Render() } func runReleaseView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") 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(), getCwd()) if err != nil { 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 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"` }{ Release: release, Assets: attachments, } return outputJSON(cmd, payload) } 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.Fprintf(ios.Out, "Target: %s\n", release.Target) } if release.Publisher != nil { fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName) } fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY)) if !release.PublishedAt.IsZero() { fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY)) } if release.HTMLURL != "" { fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL) } if release.Note != "" { fmt.Fprintf(ios.Out, "\n%s\n", release.Note) } if len(attachments) > 0 { fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments)) for _, asset := range attachments { fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) } } return nil } func runReleaseCreate(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") title, _ := cmd.Flags().GetString("title") notes, _ := cmd.Flags().GetString("notes") notesFile, _ := cmd.Flags().GetString("notes-file") draft, _ := cmd.Flags().GetBool("draft") prerelease, _ := cmd.Flags().GetBool("prerelease") target, _ := cmd.Flags().GetString("target") if notes != "" && notesFile != "" { return fmt.Errorf("use either --notes or --notes-file, not both") } tag := args[0] files := args[1:] if notesFile != "" { content, err := os.ReadFile(notesFile) if err != nil { return fmt.Errorf("failed to read notes file: %w", err) } notes = string(content) } if title == "" { title = tag } 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(), getCwd()) if err != nil { return err } ios.StartSpinner("Creating release...") release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ TagName: tag, Target: target, Title: title, Note: notes, IsDraft: draft, IsPrerelease: prerelease, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create release: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName) if 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.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } func runReleaseUpload(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") clobber, _ := cmd.Flags().GetBool("clobber") tag := args[0] files := args[1:] 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(), getCwd()) if err != nil { 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() cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } 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) if err != nil { return err } cfg, err := config.Load() if err != nil { return err } client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { 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 len(attachments) == 0 { fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName) return nil } // 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(), getCwd()) 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 } func getReleaseByTagOrLatest(client *api.Client, owner, name, tag string) (*gitea.Release, error) { if strings.EqualFold(tag, "latest") { release, _, err := client.GetLatestRelease(owner, name) if err != nil { return nil, fmt.Errorf("failed to get latest release: %w", err) } return release, nil } release, _, err := client.GetReleaseByTag(owner, name, tag) if err != nil { return nil, fmt.Errorf("failed to get release: %w", err) } return release, nil } func uploadReleaseAssets(client *api.Client, owner, name string, releaseID int64, files []string, clobber bool) error { existing := map[string]int64{} if clobber { attachments, err := listReleaseAttachments(client, owner, name, releaseID) if err != nil { return err } for _, attachment := range attachments { existing[attachment.Name] = attachment.ID } } for _, file := range files { filename := filepath.Base(file) if clobber { if attachmentID, ok := existing[filename]; ok { if _, err := client.DeleteReleaseAttachment(owner, name, releaseID, attachmentID); err != nil { return fmt.Errorf("failed to delete existing asset %s: %w", filename, err) } } } handle, err := os.Open(file) if err != nil { return fmt.Errorf("failed to open %s: %w", file, err) } _, _, err = client.CreateReleaseAttachment(owner, name, releaseID, handle, filename) closeErr := handle.Close() if err != nil { return fmt.Errorf("failed to upload %s: %w", file, err) } if closeErr != nil { return fmt.Errorf("failed to close %s: %w", file, closeErr) } } return nil } func listReleaseAttachments(client *api.Client, owner, name string, releaseID int64) ([]*gitea.Attachment, error) { var all []*gitea.Attachment for page := 1; ; page++ { attachments, _, err := client.ListReleaseAttachments(owner, name, releaseID, gitea.ListReleaseAttachmentsOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, }) if err != nil { return nil, fmt.Errorf("failed to list release assets: %w", err) } if len(attachments) == 0 { break } all = append(all, attachments...) } return all, nil } func releaseType(release *gitea.Release) string { if release.IsDraft { return "draft" } if release.IsPrerelease { return "prerelease" } return "release" } func releaseTimestamp(release *gitea.Release) time.Time { if !release.PublishedAt.IsZero() { return release.PublishedAt } return release.CreatedAt }