diff --git a/cmd/release.go b/cmd/release.go new file mode 100644 index 0000000..2e4774e --- /dev/null +++ b/cmd/release.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/MicroClub-USTHB/mctf-cli/internal/release" +) + +var releaseCmd = &cobra.Command{ + Use: "release ", + Short: "Create a versioned release and push its tag", + Long: `Prepare a release using conventional commit messages. +The command requires a clean working tree and the main or develop branch. +Use --dry-run to preview the release without creating or pushing a tag.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRelease(cmd, args[0], releaseDryRun) + }, +} + +var releaseDryRun bool + +func init() { + releaseCmd.Flags().BoolVar( + &releaseDryRun, + "dry-run", + false, + "preview release notes without creating or pushing a tag", + ) + rootCmd.AddCommand(releaseCmd) +} + +func runRelease(cmd *cobra.Command, version string, dryRun bool) error { + if err := release.ValidateVersion(version); err != nil { + return err + } + + dir := flagDir + + branch, err := gitCommand(dir, "branch", "--show-current") + if err != nil { + return fmt.Errorf("check current branch: %w", err) + } + branch = strings.TrimSpace(branch) + + if branch != "main" && branch != "develop" { + return fmt.Errorf( + "release must be run from main or develop; current branch is %q", + branch, + ) + } + + status, err := gitCommand(dir, "status", "--porcelain") + if err != nil { + return fmt.Errorf("check working tree: %w", err) + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf( + "working tree is not clean; commit or stash your changes first", + ) + } + + tags, err := gitCommand(dir, "tag", "--list", version) + if err != nil { + return fmt.Errorf("check existing release tag: %w", err) + } + for _, tag := range strings.Fields(tags) { + if tag == version { + return fmt.Errorf("tag %q already exists; refusing to overwrite it", version) + } + } + + subjects, previousTag, err := release.ReadCommitSubjects(dir) + if err != nil { + return fmt.Errorf("generate release notes: %w", err) + } + + notes := release.FormatNotes(version, subjects) + out := cmd.OutOrStdout() + + if previousTag == "" { + fmt.Fprintln(out, "No previous version tag found; using the full Git history.") + } else { + fmt.Fprintf(out, "Previous release: %s\n", previousTag) + } + + fmt.Fprintf(out, "\n%s\n", notes) + + if dryRun { + fmt.Fprintf(out, "\nDry run: planned tag is %s\n", version) + fmt.Fprintln(out, "No tag was created and nothing was pushed.") + return nil + } + + if !confirmYes( + os.Stdin, + cmd.ErrOrStderr(), + fmt.Sprintf("Create annotated tag %s and push it to origin?", version), + ) { + fmt.Fprintln(out, "Release canceled. No tag was created or pushed.") + return nil + } + + if _, err := gitCommand( + dir, + "tag", "-a", version, "-m", "Release "+version, + ); err != nil { + return fmt.Errorf("create annotated tag %s: %w", version, err) + } + + if _, err := gitCommand(dir, "push", "origin", version); err != nil { + return fmt.Errorf( + "tag %s was created locally, but pushing it failed: %w", + version, + err, + ) + } + + fmt.Fprintf(out, "Release tag %s was created and pushed to origin.\n", version) + return nil +} + +func gitCommand(dir string, args ...string) (string, error) { + gitArgs := append([]string{"-C", dir}, args...) + gitCmd := exec.Command("git", gitArgs...) + + output, err := gitCmd.CombinedOutput() + if err != nil { + details := strings.TrimSpace(string(output)) + if details != "" { + return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), details, err) + } + return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + + return string(output), nil +} diff --git a/internal/release/commits.go b/internal/release/commits.go new file mode 100644 index 0000000..fb9aa24 --- /dev/null +++ b/internal/release/commits.go @@ -0,0 +1,52 @@ +package release + +import ( + "fmt" + "os/exec" + "strings" +) + +// ReadCommitSubjects returns commit subjects since the latest reachable +// version tag. If no version tag exists, it reads the full history. +// It also returns the previous tag, or an empty string if there isn't one. +func ReadCommitSubjects(dir string) ([]string, string, error) { + tagCmd := exec.Command( + "git", "-C", dir, + "tag", "--merged", "HEAD", + "--sort=-version:refname", + ) + + tagOutput, err := tagCmd.Output() + if err != nil { + return nil, "", fmt.Errorf("list Git tags: %w", err) + } + + var previousTag string + for _, tag := range strings.Fields(string(tagOutput)) { + if ValidateVersion(tag) == nil { + previousTag = tag + break + } + } + + args := []string{"-C", dir, "log", "--format=%s"} + + if previousTag != "" { + args = append(args, previousTag+"..HEAD") + } else { + args = append(args, "HEAD") + } + + logCmd := exec.Command("git", args...) + logOutput, err := logCmd.Output() + if err != nil { + return nil, previousTag, fmt.Errorf("read commit subjects: %w", err) + } + + output := strings.TrimSpace(string(logOutput)) + if output == "" { + return []string{}, previousTag, nil + } + + return strings.Split(output, "\n"), previousTag, nil +} diff --git a/internal/release/commits_test.go b/internal/release/commits_test.go new file mode 100644 index 0000000..f9caa2b --- /dev/null +++ b/internal/release/commits_test.go @@ -0,0 +1,99 @@ +package release + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func TestReadCommitSubjectsWithoutTags(t *testing.T) { + dir := setupTestRepository(t) + + createCommit(t, dir, "first.txt", "feat: first feature") + createCommit(t, dir, "second.txt", "fix: second feature") + + subjects, previousTag, err := ReadCommitSubjects(dir) + if err != nil { + t.Fatalf("ReadCommitSubjects returned error: %v", err) + } + + if previousTag != "" { + t.Errorf("previous tag = %q, want empty string", previousTag) + } + + expected := []string{ + "fix: second feature", + "feat: first feature", + } + if !reflect.DeepEqual(subjects, expected) { + t.Errorf("subjects = %v, want %v", subjects, expected) + } +} + +func TestReadCommitSubjectsSincePreviousTag(t *testing.T) { + dir := setupTestRepository(t) + + createCommit(t, dir, "first.txt", "feat: initial feature") + runGit(t, dir, "tag", "-a", "v0.1.0", "-m", "Release v0.1.0") + + createCommit(t, dir, "second.txt", "fix: repair login") + createCommit(t, dir, "third.txt", "docs: explain login") + + subjects, previousTag, err := ReadCommitSubjects(dir) + if err != nil { + t.Fatalf("ReadCommitSubjects returned error: %v", err) + } + + if previousTag != "v0.1.0" { + t.Errorf("previous tag = %q, want %q", previousTag, "v0.1.0") + } + + expected := []string{ + "docs: explain login", + "fix: repair login", + } + if !reflect.DeepEqual(subjects, expected) { + t.Errorf("subjects = %v, want %v", subjects, expected) + } +} + +func setupTestRepository(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + runGit(t, dir, "init") + runGit(t, dir, "config", "user.name", "Release Test") + runGit(t, dir, "config", "user.email", "release-test@example.com") + + return dir +} + +func createCommit(t *testing.T, dir, filename, subject string) { + t.Helper() + + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, []byte(subject), 0644); err != nil { + t.Fatalf("write test file: %v", err) + } + + runGit(t, dir, "add", filename) + runGit(t, dir, "commit", "-m", subject) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + gitArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", gitArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf( + "git %v failed: %v\n%s", + args, + err, + output, + ) + } +} diff --git a/internal/release/notes.go b/internal/release/notes.go new file mode 100644 index 0000000..5eab9d8 --- /dev/null +++ b/internal/release/notes.go @@ -0,0 +1,104 @@ +package release + +import ( + "fmt" + "regexp" + "strings" +) + +var versionPattern = regexp.MustCompile( + `^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`, +) + +// ValidateVersion checks that a version follows the vX.Y.Z format. +func ValidateVersion(version string) error { + if !versionPattern.MatchString(version) { + return fmt.Errorf( + "invalid version %q: expected vX.Y.Z, for example v0.2.0", + version, + ) + } + return nil +} + +var conventionalCommitPattern = regexp.MustCompile( + `^([a-zA-Z][a-zA-Z0-9-]*)(\(([^)]+)\))?(!)?:[[:space:]]*(.+)$`, +) + +var categoryOrder = []string{ + "Features", + "Bug Fixes", + "Documentation", + "Performance", + "Refactoring", + "Tests", + "Build & CI", + "Chores", + "Other Changes", +} + +var categoryTypes = map[string]string{ + "feat": "Features", + "fix": "Bug Fixes", + "docs": "Documentation", + "perf": "Performance", + "refactor": "Refactoring", + "test": "Tests", + "build": "Build & CI", + "ci": "Build & CI", + "chore": "Chores", +} + +// FormatNotes groups conventional commit subjects into release notes. +func FormatNotes(version string, subjects []string) string { + groups := make(map[string][]string) + + for _, subject := range subjects { + subject = strings.TrimSpace(subject) + if subject == "" { + continue + } + + matches := conventionalCommitPattern.FindStringSubmatch(subject) + if matches == nil { + groups["Other Changes"] = append(groups["Other Changes"], subject) + continue + } + + commitType := strings.ToLower(matches[1]) + scope := matches[3] + breaking := matches[4] == "!" + description := matches[5] + + category, ok := categoryTypes[commitType] + if !ok { + category = "Other Changes" + } + + if breaking { + description += " (BREAKING CHANGE)" + } + if scope != "" { + description = scope + ": " + description + } + + groups[category] = append(groups[category], description) + } + + var output strings.Builder + fmt.Fprintf(&output, "# %s\n", version) + + for _, category := range categoryOrder { + items := groups[category] + if len(items) == 0 { + continue + } + + fmt.Fprintf(&output, "\n## %s\n", category) + for _, item := range items { + fmt.Fprintf(&output, "- %s\n", item) + } + } + + return strings.TrimSpace(output.String()) + "\n" +} diff --git a/internal/release/notes_test.go b/internal/release/notes_test.go new file mode 100644 index 0000000..b59a9ec --- /dev/null +++ b/internal/release/notes_test.go @@ -0,0 +1,90 @@ +package release + +import ( + "strings" + "testing" +) + +func TestValidateVersion(t *testing.T) { + valid := []string{ + "v0.2.0", + "v1.0.0", + "v1.2.3", + "v1.2.3-rc.1", + "v1.2.3+build.5", + } + + for _, version := range valid { + t.Run(version, func(t *testing.T) { + if err := ValidateVersion(version); err != nil { + t.Fatalf("ValidateVersion(%q) returned error: %v", version, err) + } + }) + } +} + +func TestValidateVersionRejectsInvalidVersions(t *testing.T) { + invalid := []string{ + "", + "1.2.3", + "v1", + "v1.2", + "v01.2.3", + "version1.2.3", + } + + for _, version := range invalid { + t.Run(version, func(t *testing.T) { + if err := ValidateVersion(version); err == nil { + t.Fatalf("ValidateVersion(%q) should fail", version) + } + }) + } +} + +func TestFormatNotesGroupsCommits(t *testing.T) { + subjects := []string{ + "feat(cli): add release command", + "fix: reject dirty working tree", + "docs: explain release process", + "chore: tidy dependencies", + "Merge pull request #12 from example/feature", + } + + notes := FormatNotes("v0.2.0", subjects) + + expected := []string{ + "# v0.2.0", + "## Features", + "- cli: add release command", + "## Bug Fixes", + "- reject dirty working tree", + "## Documentation", + "- explain release process", + "## Chores", + "- tidy dependencies", + "## Other Changes", + "- Merge pull request #12 from example/feature", + } + + position := 0 + for _, item := range expected { + found := strings.Index(notes[position:], item) + if found == -1 { + t.Errorf("release notes are missing %q or it is out of order", item) + continue + } + position += found + len(item) + } +} + +func TestFormatNotesHandlesEmptyCommits(t *testing.T) { + notes := FormatNotes("v0.2.0", []string{"", " "}) + + if !strings.Contains(notes, "# v0.2.0") { + t.Error("release notes should contain the version heading") + } + if strings.Contains(notes, "## Features") { + t.Error("release notes should not contain an empty Features section") + } +}