diff --git a/README.md b/README.md index 58e4ab2..c23ed91 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,25 @@ fgj repo clone owner/repo -p ssh fgj repo fork owner/repo ``` +### Releases + +```bash +# List releases +fgj release list + +# View a release (or use "latest") +fgj release view v1.2.3 + +# Create a release with notes and optional assets +fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz + +# Upload assets to an existing release +fgj release upload v1.2.3 ./dist/app.tar.gz --clobber + +# Delete a release (keeps the Git tag) +fgj release delete v1.2.3 +``` + ### Forgejo Actions ```bash diff --git a/cmd/release.go b/cmd/release.go new file mode 100644 index 0000000..eef4bdc --- /dev/null +++ b/cmd/release.go @@ -0,0 +1,450 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "code.gitea.io/sdk/gitea" + "codeberg.org/romaintb/fgj/internal/api" + "codeberg.org/romaintb/fgj/internal/config" + "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.", + RunE: runReleaseList, +} + +var releaseViewCmd = &cobra.Command{ + Use: "view ", + Short: "View a release", + Long: "Display detailed information about a release.", + 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.", + Args: cobra.MinimumNArgs(1), + RunE: runReleaseCreate, +} + +var releaseUploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Upload release assets", + Long: "Upload assets to an existing release.", + Args: cobra.MinimumNArgs(2), + RunE: runReleaseUpload, +} + +var releaseDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a release", + Long: "Delete a release by tag, keeping its Git tag intact.", + 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(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") + + releaseViewCmd.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("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") + + releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") +} + +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, "") + 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 + } + + 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 { + return fmt.Errorf("failed to list releases: %w", err) + } + if len(batch) == 0 { + break + } + releases = append(releases, batch...) + } + + if len(releases) > limit { + releases = releases[:limit] + } + + if len(releases) == 0 { + fmt.Printf("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") + 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) + } + _ = w.Flush() + + return nil +} + +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, "") + if err != nil { + return err + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + fmt.Printf("Release %s\n", release.TagName) + fmt.Printf("Title: %s\n", release.Title) + fmt.Printf("Type: %s\n", releaseType(release)) + if release.Target != "" { + fmt.Printf("Target: %s\n", release.Target) + } + if release.Publisher != nil { + fmt.Printf("Author: %s\n", release.Publisher.UserName) + } + fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05")) + if !release.PublishedAt.IsZero() { + fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) + } + if release.HTMLURL != "" { + fmt.Printf("URL: %s\n", release.HTMLURL) + } + if release.Note != "" { + fmt.Printf("\n%s\n", release.Note) + } + + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + if err != nil { + return err + } + if len(attachments) > 0 { + fmt.Printf("\nAssets (%d):\n", len(attachments)) + for _, asset := range attachments { + fmt.Printf("- %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, "") + if err != nil { + return err + } + + release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ + TagName: tag, + Target: target, + Title: title, + Note: notes, + IsDraft: draft, + IsPrerelease: prerelease, + }) + if err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Printf("Release created: %s\n", release.TagName) + if release.HTMLURL != "" { + fmt.Printf("View at: %s\n", release.HTMLURL) + } + + if len(files) == 0 { + return nil + } + + if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil { + return err + } + + fmt.Printf("Uploaded %d asset(s)\n", len(files)) + 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, "") + if err != nil { + return err + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil { + return err + } + + fmt.Printf("Uploaded %d asset(s)\n", len(files)) + return nil +} + +func runReleaseDelete(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, "") + if err != nil { + return err + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { + return fmt.Errorf("failed to delete release: %w", err) + } + + fmt.Printf("Release %s deleted\n", 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 +}