diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1e5c995 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,47 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Go files - use tabs (Go standard) +[*.go] +indent_style = tab +indent_size = 4 + +# Go mod files +[go.{mod,sum}] +indent_style = tab +indent_size = 4 + +# Makefiles - must use tabs +[{Makefile,*.mk}] +indent_style = tab +indent_size = 4 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false + +# Shell scripts +[*.sh] +indent_style = space +indent_size = 2 diff --git a/Makefile b/Makefile index 57e8eda..d11a86a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: help build run test clean lint lint-fix +.PHONY: help build run test clean lint lint-fix install help: @echo "Available commands:" @echo " make build - Build the application" + @echo " make install - Install the binary to /usr/bin" @echo " make run - Run the application" @echo " make test - Run tests" @echo " make lint - Run golangci-lint" @@ -12,6 +13,9 @@ help: build: go build -o bin/fgj . +install: build + install -Dm755 bin/fgj /usr/bin/fgj + run: go run . 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 +} diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 5d662dd..7fed7a1 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -1,3 +1,4 @@ +//go:build functional // +build functional package functional @@ -7,6 +8,7 @@ import ( "fmt" "os" "testing" + "time" "code.gitea.io/sdk/gitea" ) @@ -620,3 +622,85 @@ func TestCLIRepoClone(t *testing.T) { t.Logf("Successfully cloned repository to %s via CLI", clonePath) } + +// TestCLIReleaseCreateUploadDelete verifies release create/upload/delete via CLI. +func TestCLIReleaseCreateUploadDelete(t *testing.T) { + env := NewTestEnv(t) + + tag := fmt.Sprintf("fgj-test-%d", time.Now().UnixNano()) + title := "FGJ CLI Release Test" + notes := "Release created by functional tests" + + tmpDir := t.TempDir() + assetPath := fmt.Sprintf("%s/asset.txt", tmpDir) + if err := os.WriteFile(assetPath, []byte("fgj release asset"), 0600); err != nil { + t.Fatalf("failed to create asset file: %v", err) + } + + result := env.RunCLI( + "--hostname", env.Hostname, + "release", "create", tag, + "-t", title, + "-n", notes, + assetPath, + ) + if result.ExitCode != 0 { + t.Fatalf("release create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + release, _, err := env.Client.GetReleaseByTag(env.Owner, env.RepoName, tag) + if err != nil { + t.Fatalf("failed to fetch created release: %v", err) + } + if release.Title != title { + t.Fatalf("expected release title %q, got %q", title, release.Title) + } + + attachments, _, err := env.Client.ListReleaseAttachments(env.Owner, env.RepoName, release.ID, gitea.ListReleaseAttachmentsOptions{}) + if err != nil { + t.Fatalf("failed to list release assets: %v", err) + } + found := false + for _, attachment := range attachments { + if attachment.Name == "asset.txt" { + found = true + break + } + } + if !found { + t.Fatalf("uploaded asset not found in release") + } + + deleteResult := env.RunCLI( + "--hostname", env.Hostname, + "release", "delete", tag, + ) + if deleteResult.ExitCode != 0 { + t.Fatalf("release delete failed with exit code %d: %s", deleteResult.ExitCode, deleteResult.Stderr) + } + + _, _, err = env.Client.GetReleaseByTag(env.Owner, env.RepoName, tag) + if err == nil { + t.Fatalf("expected release %s to be deleted, but it still exists", tag) + } +} + +// TestCLIReleaseList verifies the `fgj release list` command works. +func TestCLIReleaseList(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "release", "list", + ) + + if result.ExitCode != 0 { + t.Fatalf("release list failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if result.Stdout == "" && result.Stderr == "" { + t.Logf("Note: release list produced no output") + } + + t.Logf("Successfully listed releases via CLI") +}