From 533e46c8fc3aef10aff776c1c2ee80a5e3499382 Mon Sep 17 00:00:00 2001 From: Dimitris Baltas Date: Fri, 8 Mar 2019 14:59:46 +0200 Subject: [PATCH] Revert "deleting code to aid a thorough code review" This reverts commit 144300f82e18426908358bfac1acf6dc10118d8a. --- cmd/deploy.go | 159 +++++++++++++++++++++++++++++++ cmd/draft.go | 107 +++++++++++++++++++++ cmd/github/main.go | 67 +++++++++++++ cmd/pr.go | 144 ++++++++++++++++++++++++++++ cmd/prs.go | 56 +++++++++++ cmd/root.go | 176 +++++++++++++++++++++++++++++++++++ cmd/status.go | 87 +++++++++++++++++ cmd/version.go | 20 ++++ github/github.go | 172 ++++++++++++++++++++++++++++++++++ jira/auth.go | 35 +++++++ jira/auth_test.go | 45 +++++++++ jira/jira.go | 125 +++++++++++++++++++++++++ jira/jira_test.go | 105 +++++++++++++++++++++ repo/branch.go | 82 ++++++++++++++++ repo/format.go | 36 +++++++ repo/repo.go | 146 +++++++++++++++++++++++++++++ repo/repo_functional_test.go | 71 ++++++++++++++ 17 files changed, 1633 insertions(+) create mode 100644 cmd/deploy.go create mode 100644 cmd/draft.go create mode 100644 cmd/github/main.go create mode 100644 cmd/pr.go create mode 100644 cmd/prs.go create mode 100644 cmd/root.go create mode 100644 cmd/status.go create mode 100644 cmd/version.go create mode 100644 github/github.go create mode 100644 jira/auth.go create mode 100644 jira/auth_test.go create mode 100644 jira/jira.go create mode 100644 jira/jira_test.go create mode 100644 repo/branch.go create mode 100644 repo/format.go create mode 100644 repo/repo.go create mode 100644 repo/repo_functional_test.go diff --git a/cmd/deploy.go b/cmd/deploy.go new file mode 100644 index 0000000..0d17440 --- /dev/null +++ b/cmd/deploy.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var releaseInterval string +var releaseOffset string +var allowForcePush bool + +func init() { + rootCmd.AddCommand(deployCmd) + deployCmd.Flags().StringVar(&releaseOffset, "releaseOffset", "1m", "Duration to wait before the first release ('5m', '1h25m', '30s')") + deployCmd.Flags().StringVar(&releaseInterval, "releaseInterval", "25m", "Duration to wait between releases. ('5m', '1h25m', '30s')") + deployCmd.Flags().BoolVar(&allowForcePush, "force", false, "Allow force push if deploy branch has diverged from base") +} + +var deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy base branch to target branches", + Long: `Deploy base branch to target branches`, + RunE: func(cmd *cobra.Command, args []string) error { + return deployBranches( + viper.GetStringMapString("release.branch-map"), + viper.GetString("release.on-deploy.body-branch-suffix-find"), + viper.GetString("release.on-deploy.body-branch-suffix-replace"), + ) + }, +} + +func deployBranches(branchMap map[string]string, suffixFind, suffixReplace string) error { + blue := color.New(color.FgCyan) + yellow := color.New(color.FgYellow) + green := color.New(color.FgGreen) + + reference := baseBranch + remote := r.GitRemote().Config().Name + releaseRepo := r.Name() + path := r.Path() + integrateGithubRelease := releaseRepo != "" + + if integrateGithubRelease { + release, err := gc.LastRelease() + if err != nil { + return err + } + reference = release.GetTagName() + green.Printf("Deploying %s\n", release.GetHTMLURL()) + } + + blue.Printf("Release reference: %s\n", reference) + green.Println("Deployment start times are estimates.") + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Branch", "Start Time") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + intervalDuration, err := time.ParseDuration(releaseInterval) + if err != nil { + return fmt.Errorf("error parsing interval: %v", err) + } + offsetDuration, err := time.ParseDuration(releaseOffset) + if err != nil { + return fmt.Errorf("error parsing offset: %v", err) + } + + t := time.Now() + t = t.Add(offsetDuration) + firstRelease := t + for _, branch := range releaseBranches { + tbl.AddRow(branch, t.Format("15:04")) + t = t.Add(intervalDuration) + } + + tbl.Print() + reader := bufio.NewReader(os.Stdin) + yellow.Printf("Press 'ok' to continue with Deployment:") + input, _ := reader.ReadString('\n') + text := strings.Split(input, "\n")[0] + if text != "ok" { + fmt.Printf("No deployment\n") + return nil + } + fmt.Println(text) + + if firstRelease.Before(time.Now()) { + yellow.Println("\ndeployment stopped since first released time has passed. Please run again") + return nil + } + + d := time.Until(firstRelease) + green.Printf("Deployment will start in %s\n", d.String()) + time.Sleep(d) + + for i, branch := range releaseBranches { + if i != 0 { + time.Sleep(intervalDuration) + t = t.Add(intervalDuration) + } + green.Printf("%s Deploying %s\n", time.Now().Format("15:04:05"), branch) + cmd := "" + // if reference is a branch name, use origin + pushFlag := "" + if allowForcePush { + pushFlag = "-f" + } + if reference == baseBranch { + cmd = fmt.Sprintf("cd %s && git push %s %s %s/%s:%s", path, pushFlag, remote, remote, reference, branch) + } else { // if reference is a tag don't prefix with origin + cmd = fmt.Sprintf("cd %s && git push %s %s %s:%s", path, pushFlag, remote, reference, branch) + } + green.Printf("%s Executing %s\n", time.Now().Format("15:04:05"), cmd) + out, err := exec.Command("sh", "-c", cmd).Output() + + if err != nil { + return fmt.Errorf("error executing command %s:%v", cmd, err) + } + green.Printf("%s Triggered Successfully %s\n", time.Now().Format("15:04:05"), strings.TrimSpace(string(out))) + + branchText, ok := branchMap[branch] + if !ok { + branchText = branch + } + if integrateGithubRelease && suffixFind != "" { + t := time.Now() + green.Printf("%s Updating release on github %s\n", time.Now().Format("15:04:05"), strings.TrimSpace(string(out))) + release, err := gc.LastRelease() + if err != nil { + return err + } + + findText := fmt.Sprintf("%s ![](https://img.shields.io/badge/released%s)", branchText, suffixFind) + replaceText := fmt.Sprintf("%s ![](https://img.shields.io/badge/released-%d_%s_%d_%02d:%02d%s)", branchText, t.Day(), t.Month(), t.Year(), t.Hour(), t.Minute(), suffixReplace) + newBody := strings.Replace(*(release.Body), findText, replaceText, -1) + fmt.Println(newBody) + release.Body = &newBody + _, err = gc.EditRelease(release) + if err != nil { + return err + } + + green.Printf("%s Updated release on github %s\n", time.Now().Format("15:04:05"), strings.TrimSpace(string(out))) + } + } + + return nil +} diff --git a/cmd/draft.go b/cmd/draft.go new file mode 100644 index 0000000..826321a --- /dev/null +++ b/cmd/draft.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/dbaltas/ergo/github" + "github.com/dbaltas/ergo/repo" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var releaseTag string +var updateJiraFixVersions bool + +func init() { + rootCmd.AddCommand(draftCmd) + draftCmd.Flags().StringVar(&releaseTag, "releaseTag", "", "Tag for the release. If empty, curent date in YYYY.MM.DD will be used") + draftCmd.Flags().BoolVar(&updateJiraFixVersions, "update-jira-fix-versions", false, "Update fix versions on Jira based on the configuration string") + +} + +var draftCmd = &cobra.Command{ + Use: "draft", + Short: "Create a draft release [github]", + Long: `Create a draft release on github comparing one target branch with the base branch`, + RunE: func(cmd *cobra.Command, args []string) error { + return draftRelease() + }, +} + +func draftRelease() error { + yellow := color.New(color.FgYellow) + branchMap := viper.GetStringMapString("release.branch-map") + + t := time.Now() + + tagName := releaseTag + if tagName == "" { + tagName = fmt.Sprintf("%4d.%02d.%02d", t.Year(), t.Month(), t.Day()) + } + name := fmt.Sprintf("%s %d %d", t.Month(), t.Day(), t.Year()) + + var diff []repo.DiffCommitBranch + + branches := strings.Split(releaseBranchesString, ",") + for _, branch := range branches { + ahead, behind, err := r.CompareBranch(baseBranch, branch) + if err != nil { + return fmt.Errorf("error comparing %s %s:%s", baseBranch, branch, err) + } + branchCommitDiff := repo.DiffCommitBranch{ + Branch: branch, + BaseBranch: baseBranch, + Ahead: ahead, + Behind: behind, + } + diff = append(diff, branchCommitDiff) + } + + releaseBody := github.ReleaseBody(diff, viper.GetString("github.release-body-prefix"), branchMap) + if updateJiraFixVersions { + // Eliminate duplicates by using a map + uTasks := make(map[string]bool) + taskRegExp := viper.GetString("jira.task-regex") + + re := regexp.MustCompile(fmt.Sprintf("(?m)(%v)", taskRegExp)) + for _, commit := range diff[0].Behind { + res := re.FindAllStringSubmatch(commit.Message, -1) + if len(res) > 0 { + for _, task := range res { + uTasks[task[0]] = true + } + } + } + + // Get all the values + tasks := make([]string, 0, len(uTasks)) + for task := range uTasks { + tasks = append(tasks, task) + } + jc.UpdateIssueFixVersions(tasks) + } + + fmt.Println(releaseBody) + reader := bufio.NewReader(os.Stdin) + yellow.Printf("Press 'ok' to continue with Drafting the release:") + input, _ := reader.ReadString('\n') + text := strings.Split(input, "\n")[0] + if text != "ok" { + fmt.Printf("No draft\n") + return nil + } + + release, err := gc.CreateDraftRelease(name, tagName, releaseBody) + + if err != nil { + return err + } + fmt.Println(release) + return nil +} diff --git a/cmd/github/main.go b/cmd/github/main.go new file mode 100644 index 0000000..83c519d --- /dev/null +++ b/cmd/github/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + + "github.com/google/go-github/github" + "github.com/spf13/viper" + "golang.org/x/oauth2" +) + +func main() { + viper.AddConfigPath(".") + err := viper.ReadInConfig() // Find and read the config file + if err != nil { // Handle errors reading the config file + fmt.Printf("error reading config file: %v", err) + return + } + + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: viper.GetString("github.accessToken")}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + + // list all repositories for the authenticated user + repo, resp, err := client.Repositories.Get(ctx, "taxibeat", "rest") + if err != nil { + fmt.Printf("error list repos: %v\n", err) + return + } + + fmt.Println(resp) + fmt.Println(repo.Name) + + // tagName := "2018.04.19" + // name := "April 19 2018" + // isDraft := true + // releaseBody := "this is a test release body created by ergo!" + // release := &github.RepositoryRelease{ + // Name: &name, + // TagName: &tagName, + // Draft: &isDraft, + // Body: &releaseBody, + // } + // rel, resp, err := client.Repositories.CreateRelease(ctx, "taxibeat", "rest", release) + // if err != nil { + // fmt.Printf("error list repos: %v\n", err) + // return + // } + + // fmt.Println(resp) + // fmt.Println(rel) + + repos, _, err := client.Repositories.ListByOrg(ctx, "taxibeat", &github.RepositoryListByOrgOptions{ + Type: "All", + }) + if err != nil { + fmt.Printf("error list repos: %v\n", err) + return + } + for _, repo := range repos { + fmt.Println(*(repo.IssuesURL)) + } +} diff --git a/cmd/pr.go b/cmd/pr.go new file mode 100644 index 0000000..17adc93 --- /dev/null +++ b/cmd/pr.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var compareBranch string +var title string +var description string +var number int +var reviewers string +var teamReviewers string + +func init() { + rootCmd.AddCommand(prCmd) + prCmd.Flags().StringVar(&compareBranch, "compare", "", "The branch to compare with base branch. Defaults to current local branch.") + prCmd.Flags().StringVar(&title, "title", "", "The title of the PR.") + prCmd.Flags().StringVar(&reviewers, "reviewers", "", "Add reviewers.") + prCmd.Flags().StringVar(&teamReviewers, "teamReviewers", "", "Add a team as reviewers.") + prCmd.Flags().StringVar(&description, "description", "", "The description of the PR.") +} + +var prCmd = &cobra.Command{ + Use: "pr", + Short: "Create or show a pull request [github]", + Long: `Create a pull request on github from compare branch to base branch + ergo pr --title "my new pull request --reviewers pespantelis,nstratos,mantzas" +Show details of a pr by pr number + ergo pr 18 +Add reviewers to an existing pr + ergo pr 18 --reviewers pespantelis,nstratos,mantzas" + `, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // show a PR by number + if len(args) > 0 { + number, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + err = getPR(number) + if err != nil { + return err + } + + if reviewers != "" || teamReviewers != "" { + _, err = gc.RequestReviewersForPR(number, reviewers, teamReviewers) + if err != nil { + return err + } + } + + return nil + } + + return createPR() + }, +} + +func createPR() error { + var err error + yellow := color.New(color.FgYellow) + + if compareBranch == "" { + compareBranch, err = r.CurrentBranch() + if err != nil { + fmt.Println(err) + } + } + + fmt.Printf(`Create a PR + base:%s + compare:%s + title:%s + description:%s +`, baseBranch, compareBranch, title, description) + + yellow.Printf("\nPress 'ok' to continue:") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + text := strings.Split(input, "\n")[0] + if text != "ok" { + fmt.Printf("No PR\n") + return nil + } + + pr, err := gc.CreatePR(baseBranch, compareBranch, title, description) + if err != nil { + return err + } + + fmt.Printf("Created PR %s\n", *pr.HTMLURL) + + if reviewers != "" || teamReviewers != "" { + _, err = gc.RequestReviewersForPR(pr.GetNumber(), reviewers, teamReviewers) + if err != nil { + return err + } + } + + return nil +} + +func getPR(number int) error { + yellow := color.New(color.FgYellow) + green := color.New(color.FgGreen) + red := color.New(color.FgRed) + + prp, err := gc.GetPR(number) + if err != nil { + return err + } + pr := *prp + + fmt.Println() + green.Printf("#%d: %s\n", pr.GetNumber(), pr.GetTitle()) + fmt.Printf("into:%s from:%s\n", yellow.Sprint(pr.Base.GetLabel()), yellow.Sprint(pr.Head.GetLabel())) + if pr.GetBody() != "" { + yellow.Println(pr.GetBody()) + } + fmt.Println() + + fmt.Printf("%s: %d\n", yellow.Sprint("# Commits"), pr.GetCommits()) + fmt.Printf("%s:%s, %s:%s, %s:%s\n", + yellow.Sprint("created"), pr.GetCreatedAt().Format("2006-01-02 15:04"), + yellow.Sprint("modified"), pr.GetUpdatedAt().Format("2006-01-02 15:04"), + yellow.Sprint("merged"), pr.GetMergedAt().Format("2006-01-02 15:04"), + ) + fmt.Println() + + a := green.Sprintf("%d", pr.GetAdditions()) + d := red.Sprintf("%d", pr.GetDeletions()) + c := yellow.Sprintf("%d", pr.GetChangedFiles()) + fmt.Printf("%s files changed, %s additions, %s deletions\n", c, a, d) + + return nil +} diff --git a/cmd/prs.go b/cmd/prs.go new file mode 100644 index 0000000..a81a8ef --- /dev/null +++ b/cmd/prs.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "strings" + + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(prsCmd) +} + +var prsCmd = &cobra.Command{ + Use: "prs", + Short: "List open pull requests [github]", + Long: `List open pull requests on github`, + RunE: func(cmd *cobra.Command, args []string) error { + return listPRs() + }, +} + +func listPRs() error { + var err error + + prs, err := gc.ListPRs() + if err != nil { + return err + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("#", "Title", "Branch", "Url", "Creator", "Created") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, pr := range prs { + branch := *pr.Head.Label + if strings.HasPrefix(branch, organizationName+":") { + branch = strings.TrimPrefix(branch, organizationName+":") + } + title := (*pr.Title) + if len(title) > 60 { + title = title[:60] + } + t := *pr.CreatedAt + + at := t.Format("2006-01-02 15:04") + tbl.AddRow(*pr.Number, title, branch, *pr.HTMLURL, (*pr.User).GetLogin(), at) + } + + tbl.Print() + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9c28128 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/dbaltas/ergo/github" + "github.com/dbaltas/ergo/jira" + "github.com/dbaltas/ergo/repo" + "github.com/fatih/color" + homedir "github.com/mitchellh/go-homedir" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + repoURL string + path string + skipFetch bool + baseBranch string + branchesString string + releaseBranchesString string + branches []string + releaseBranches []string + organizationName string + repoName string + + gc *github.Client + jc *jira.Client + r *repo.Repo +) + +var rootCmd = &cobra.Command{ + Use: "ergo", + Short: "ergo is a tool that aims to help the daily developer workflow", + Long: `Ergo helps to +* compare multiple branches +* push to multiple branches with time interval (useful for multiple release environments) +* minimize the browser interaction with github: + * create/show a pull request + * list open pull requests + * add reviewers to a pull request + * draft a release + * update release notes +`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Hola! type `ergo help`") + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + // commands not requiring a repo + noRepoCmds := make(map[string]bool) + // commands not requiring to fetch from a repo + skipFetchCmds := make(map[string]bool) + + noRepoCmds["help"] = true + noRepoCmds["version"] = true + skipFetchCmds["pr"] = true + skipFetchCmds["prs"] = true + + if _, ok := noRepoCmds[cmd.Name()]; ok { + return nil + } + + if _, ok := skipFetchCmds[cmd.Name()]; ok { + skipFetch = true + } + + err = initializeRepo() + if err != nil { + return fmt.Errorf("initialize repo: %v", err) + } + + gc, err = github.NewClient(context.Background(), viper.GetString("github.access-token"), organizationName, repoName) + if err != nil { + // NOTE: ergo may still be of use without github support + fmt.Printf("Error Initializing github %v\n", err) + } + + tp := jira.BasicAuthTransport{ + Username: viper.GetString("jira.username"), + Password: viper.GetString("jira.password"), + } + jc, err = jira.NewClient(tp.Client(), viper.GetString("jira.url")) + + if err != nil { + fmt.Printf("Error Initializing jira client %v\n", err) + } + + return nil + }, +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&repoURL, "repoUrl", "", "git repo Url. ssh and https supported") + rootCmd.PersistentFlags().StringVar(&path, "path", ".", "Location to store or retrieve from the repo") + rootCmd.PersistentFlags().BoolVar(&skipFetch, "skipFetch", false, "Skip fetch. When set you may not be up to date with remote") + + rootCmd.PersistentFlags().StringVar(&branchesString, "branches", "", "Comma separated list of branches") + rootCmd.PersistentFlags().StringVar(&baseBranch, "base", "", "Base branch for the comparison.") +} + +func initConfig() { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + viper.AddConfigPath(home) + viper.SetConfigName(".ergo") + err = viper.ReadInConfig() + if err != nil { + fmt.Printf("error reading config file: %v\n", err) + os.Exit(1) + } + if baseBranch == "" { + baseBranch = viper.GetString("generic.base-branch") + } +} + +func initializeRepo() error { + var err error + if repoURL != "" { + r, err = repo.NewClone(repoURL, path, viper.GetString("generic.remote")) + } else { + r, err = repo.NewFromPath(path, viper.GetString("generic.remote")) + } + if err != nil { + return fmt.Errorf("load repo:%s", err) + } + + if !skipFetch { + err = r.Fetch() + if err != nil { + return err + } + } + + repoName = r.Name() + organizationName = r.OrganizationName() + + releaseBranchesString = branchesString + if branchesString == "" { + branchesString = viper.GetString(fmt.Sprintf("repos.%s.status-branches", repoName)) + releaseBranchesString = viper.GetString(fmt.Sprintf("repos.%s.release-branches", repoName)) + } + + if branchesString == "" { + branchesString = viper.GetString("generic.status-branches") + } + + if releaseBranchesString == "" { + releaseBranchesString = viper.GetString("generic.release-branches") + } + + branches = strings.Split(branchesString, ",") + releaseBranches = strings.Split(releaseBranchesString, ",") + + yellow := color.New(color.FgYellow) + yellow.Printf("%s/%s\n", organizationName, repoName) + + return nil +} + +// Execute entry point for commands +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..969b31f --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + + "github.com/dbaltas/ergo/repo" + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/spf13/cobra" +) + +var detail bool + +func init() { + rootCmd.AddCommand(statusCmd) + rootCmd.Flags().BoolVar(&detail, "detail", false, "Print commits in detail") +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Print the status of branches compared to base branch", + Long: `Prints the commits ahead and behind of status branches compared to a base branch`, + RunE: func(cmd *cobra.Command, args []string) error { + var diff []repo.DiffCommitBranch + + for _, branch := range branches { + ahead, behind, err := r.CompareBranch(baseBranch, branch) + if err != nil { + return fmt.Errorf("error comparing %s %s:%v", baseBranch, branch, err) + } + branchCommitDiff := repo.DiffCommitBranch{ + Branch: branch, + BaseBranch: baseBranch, + Ahead: ahead, + Behind: behind, + } + diff = append(diff, branchCommitDiff) + } + + if detail { + printDetail(diff) + + return nil + } + printBranchCompare(diff) + + return nil + }, +} + +func printBranchCompare(commitDiffBranches []repo.DiffCommitBranch) { + blue := color.New(color.FgCyan) + yellow := color.New(color.FgYellow) + fmt.Println() + blue.Print("BASE: ") + yellow.Println(commitDiffBranches[0].BaseBranch) + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Branch", "Behind", "Ahead") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, diffBranch := range commitDiffBranches { + tbl.AddRow(diffBranch.Branch, len(diffBranch.Behind), len(diffBranch.Ahead)) + } + + tbl.Print() +} + +func printDetail(commitDiffBranches []repo.DiffCommitBranch) { + blue := color.New(color.FgCyan) + yellow := color.New(color.FgYellow) + fmt.Println() + blue.Print("BASE: ") + yellow.Println(commitDiffBranches[0].BaseBranch) + + firstLinePrefix := "- [ ] " + nextLinePrefix := " " + lineSeparator := "\r\n" + + for _, diffBranch := range commitDiffBranches { + for _, commit := range diffBranch.Behind { + fmt.Printf("%s%s", repo.FormatMessage(commit, firstLinePrefix, nextLinePrefix, lineSeparator), lineSeparator) + } + } +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..ac86ac5 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version of ergo", + Long: `Print the version of ergo`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("0.2.4") + }, +} diff --git a/github/github.go b/github/github.go new file mode 100644 index 0000000..6afa561 --- /dev/null +++ b/github/github.go @@ -0,0 +1,172 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/dbaltas/ergo/repo" + "github.com/google/go-github/github" + "golang.org/x/oauth2" +) + +// Client for github API +type Client struct { + ctx context.Context + accessToken string + organization string + repo string + client *github.Client +} + +// NewClient instantiate a Client +func NewClient(ctx context.Context, accessToken, organization, repo string) (*Client, error) { + if accessToken == "" { + return nil, fmt.Errorf("github.access_token not defined in config") + } + + client := githubClient(ctx, accessToken) + return &Client{ + ctx: ctx, + accessToken: accessToken, + organization: organization, + repo: repo, + client: client, + }, nil +} + +// CreateDraftRelease creates a draft release. +func (gc *Client) CreateDraftRelease(name, tagName, releaseBody string) (*github.RepositoryRelease, error) { + isDraft := true + release := &github.RepositoryRelease{ + Name: &name, + TagName: &tagName, + Draft: &isDraft, + Body: &releaseBody, + } + + release, _, err := gc.client.Repositories.CreateRelease( + gc.ctx, + gc.organization, + gc.repo, + release, + ) + + return release, err +} + +// LastRelease fetches the latest release for a repository. +func (gc *Client) LastRelease() (*github.RepositoryRelease, error) { + release, _, err := gc.client.Repositories.GetLatestRelease( + gc.ctx, gc.organization, gc.repo) + + return release, err +} + +// EditRelease allows to edit a repository release. +func (gc *Client) EditRelease(release *github.RepositoryRelease) (*github.RepositoryRelease, error) { + release, _, err := gc.client.Repositories.EditRelease( + gc.ctx, gc.organization, gc.repo, *(release.ID), release) + + return release, err +} + +// CreatePR creates a pull request +func (gc *Client) CreatePR(baseBranch, compareBranch, title, body string) (*github.PullRequest, error) { + pull := &github.NewPullRequest{ + Title: &title, + Head: &compareBranch, + Base: &baseBranch, + Body: &body, + } + + pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.organization, gc.repo, pull) + if err != nil { + return nil, err + } + + return pr, nil +} + +// GetPR gets a pull request +func (gc *Client) GetPR(number int) (*github.PullRequest, error) { + pr, _, err := gc.client.PullRequests.Get(gc.ctx, gc.organization, gc.repo, number) + if err != nil { + return nil, err + } + + return pr, nil +} + +// RequestReviewersForPR assigns reviewers to a pull request +func (gc *Client) RequestReviewersForPR(number int, reviewers, teamReviewers string) (*github.PullRequest, error) { + payload := github.ReviewersRequest{ + Reviewers: strings.Split(reviewers, ","), + TeamReviewers: strings.Split(teamReviewers, ","), + } + fmt.Println(github.Stringify(payload)) + pr, _, err := gc.client.PullRequests.RequestReviewers(gc.ctx, gc.organization, gc.repo, number, payload) + if err != nil { + return nil, err + } + + return pr, nil +} + +// ListPRs creates a pull request +func (gc *Client) ListPRs() ([]*github.PullRequest, error) { + opt := &github.PullRequestListOptions{ + Sort: "created", + Direction: "desc", + } + + pulls, _, err := gc.client.PullRequests.List(gc.ctx, gc.organization, gc.repo, opt) + if err != nil { + return nil, err + } + + return pulls, nil +} + +// ReleaseBody output needed for github release body. +func ReleaseBody(commitDiffBranches []repo.DiffCommitBranch, releaseBodyPrefix string, branchMap map[string]string) string { + var formattedCommits []string + var formattedBranches []string + var header, body string + + firstLinePrefix := "- [ ] " + nextLinePrefix := " " + lineSeparator := "\r\n" + + for _, diffBranch := range commitDiffBranches { + branchText, ok := branchMap[diffBranch.Branch] + if !ok { + branchText = branchMap[diffBranch.Branch] + } + formattedBranches = append(formattedBranches, + fmt.Sprintf("%s ![](https://img.shields.io/badge/released-No-red.svg)", branchText)) + } + + for _, commit := range commitDiffBranches[0].Behind { + formattedCommits = append(formattedCommits, repo.FormatMessage(commit, firstLinePrefix, nextLinePrefix, lineSeparator)) + body = fmt.Sprintf("%s%s%s", + body, + repo.FormatMessage(commit, firstLinePrefix, nextLinePrefix, lineSeparator), + lineSeparator) + } + + header = strings.Join(formattedBranches, " ") + body = strings.Join(formattedCommits, lineSeparator) + parts := []string{header, releaseBodyPrefix, body} + + return strings.Join(parts, strings.Repeat(lineSeparator, 2)) +} + +func githubClient(ctx context.Context, accessToken string) *github.Client { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: accessToken}, + ) + tc := oauth2.NewClient(ctx, ts) + + return github.NewClient(tc) +} diff --git a/jira/auth.go b/jira/auth.go new file mode 100644 index 0000000..0565024 --- /dev/null +++ b/jira/auth.go @@ -0,0 +1,35 @@ +package jira + +import ( + "net/http" +) + +// BasicAuthTransport is the Credentials struct +type BasicAuthTransport struct { + Username string + Password string +} + +// RoundTrip Appends the Credentials after cloning the request +func (bat BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqClone := cloneRequest(req) + reqClone.SetBasicAuth(bat.Username, bat.Password) + return http.DefaultTransport.RoundTrip(reqClone) +} + +// Client Returns the client of the Transport +func (bat *BasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: bat} +} + +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} diff --git a/jira/auth_test.go b/jira/auth_test.go new file mode 100644 index 0000000..4760959 --- /dev/null +++ b/jira/auth_test.go @@ -0,0 +1,45 @@ +package jira + +import ( + "encoding/base64" + "net/http" + "strings" + "testing" +) + +func TestAuth_RoundTrip(t *testing.T) { + setup() + defer teardown() + + req, _ := testClient.NewRequest("GET", "/fake/auth/endpoint", nil) + testMux.HandleFunc("/fake/auth/endpoint", func(w http.ResponseWriter, r *http.Request) { + if req == r { + t.Errorf("Request not cloned %v %v", r, req) + } + + authHeader := r.Header.Get("Authorization") + if len(authHeader) == 0 { + t.Errorf("No Authorization Header set") + } + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Basic" { + t.Errorf("Invalid header format %v", authHeader) + } + + credsStr := parts[1] + decoded, err := base64.StdEncoding.DecodeString(credsStr) + if err != nil { + t.Errorf("Credentials %v not base64 encoded", credsStr) + } + + creds := strings.Split(string(decoded), ":") + if len(creds) != 2 || creds[0] != "test-user" || creds[1] != "test-password" { + t.Errorf("Credentials %v don't match with test-user:test-password", string(decoded)) + } + }) + + _, err := testClient.client.Do(req) + if err != nil { + t.Errorf("Error while updating fixed versions %v", err) + } +} diff --git a/jira/jira.go b/jira/jira.go new file mode 100644 index 0000000..0fa16d6 --- /dev/null +++ b/jira/jira.go @@ -0,0 +1,125 @@ +package jira + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/spf13/viper" +) + +// Client JIRA consumer struct +type Client struct { + client *http.Client + baseURL *url.URL +} + +// NewClient Creates a new JIRA Client +func NewClient(httpClient *http.Client, baseURL string) (*Client, error) { + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + + c := &Client{client: httpClient, baseURL: parsedBaseURL} + return c, nil +} + +// NewRequest creates a new http request with a specified **method**. The final url is baseURL + endpoint +func (c *Client) NewRequest(method string, endpoint string, body interface{}) (*http.Request, error) { + parsedEndpoint, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + // Relative URLs should be specified without a preceding slash since baseURL will have the trailing slash + parsedEndpoint.Path = strings.TrimLeft(parsedEndpoint.Path, "/") + + url := c.baseURL.ResolveReference(parsedEndpoint) + + // Set request body + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, url.String(), buf) + if err != nil { + return nil, err + } + // We are only working with JSON + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +// UpdateIssue is a Generic update issue based on a JSON like paylod +func (c *Client) UpdateIssue(issueID string, data map[string]interface{}) (*http.Response, error) { + endpoint := fmt.Sprintf("/rest/api/2/issue/%s", issueID) + req, err := c.NewRequest("PUT", endpoint, data) + if err != nil { + return nil, err + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + return res, nil +} + +// UpdateIssueFixVersions Updates fixed versions of an array of tasks +func (c *Client) UpdateIssueFixVersions(tasks []string) error { + fv := viper.GetString("jira.draft-version") + for _, task := range tasks { + _, err := c.UpdateIssue(task, NewFixedVersionBody(fv)) + if err != nil { + return err + } + fmt.Printf("Changing fixed version for task %v to %v\n", task, fv) + } + return nil +} + +// NewActionBody creates JSON payload for a new action +func NewActionBody(action string, updateOp map[string]interface{}) map[string]interface{} { + root := make(map[string]interface{}) + root[action] = make(map[string]interface{}) + root[action] = updateOp + return root +} + +// NewUpdateOp creates JSON payload for a new update operation +func NewUpdateOp(op string, opData []map[string]interface{}) map[string]interface{} { + updateOp := make(map[string]interface{}) + updateOp[op] = opData + + return NewActionBody("update", updateOp) +} + +// NewFixedVersionBody Creates the fixed body version payload +func NewFixedVersionBody(v string) map[string]interface{} { + var setOps [1]map[string]string + nameOp := make(map[string]string) + nameOp["name"] = v + setOps[0] = nameOp + + var fixedVersions []map[string]interface{} + fixedVersions = make([]map[string]interface{}, 1) + + fv := make(map[string]interface{}) + fv["set"] = setOps + fixedVersions[0] = fv + + return NewUpdateOp("fixVersions", fixedVersions) +} diff --git a/jira/jira_test.go b/jira/jira_test.go new file mode 100644 index 0000000..e651a7f --- /dev/null +++ b/jira/jira_test.go @@ -0,0 +1,105 @@ +package jira + +import ( + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +var ( + testClient *Client + testMux *RegexpHandler + testServer *httptest.Server +) + +func setup() { + testMux = &RegexpHandler{} + testServer = httptest.NewServer(testMux) + testBat := BasicAuthTransport{ + Username: "test-user", + Password: "test-password", + } + testClient, _ = NewClient(testBat.Client(), testServer.URL) +} + +func teardown() { + testServer.Close() +} + +type route struct { + pattern *regexp.Regexp + handler http.Handler +} + +type RegexpHandler struct { + routes []*route +} + +func (h *RegexpHandler) Handler(r string, handler http.Handler) { + pattern := regexp.MustCompile(r) + h.routes = append(h.routes, &route{pattern, handler}) +} + +func (h *RegexpHandler) HandleFunc(r string, handler func(http.ResponseWriter, *http.Request)) { + pattern := regexp.MustCompile(r) + h.routes = append(h.routes, &route{pattern, http.HandlerFunc(handler)}) +} + +func (h *RegexpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + for _, route := range h.routes { + if route.pattern.MatchString(r.URL.Path) { + route.handler.ServeHTTP(w, r) + return + } + } + http.NotFound(w, r) +} + +func TestJira_UpdateIssue(t *testing.T) { + setup() + defer teardown() + + testMux.HandleFunc("/rest/api/2/issue/test-1234", func(w http.ResponseWriter, r *http.Request) { + if m := r.Method; m != "PUT" { + t.Errorf("Incorrect HTTP Method. Expected PUT got %v", m) + } + + if u := r.URL.String(); !strings.HasPrefix("/rest/api/2/issue/test-1234", u) { + t.Errorf("Incorrect URL. Expected /rest/api/2/issue/test-1234, got %v", u) + } + + w.WriteHeader(http.StatusNoContent) + }) + + id := "test-1234" + payload := make(map[string]interface{}) + fields := make(map[string]interface{}) + payload["fields"] = fields + _, err := testClient.UpdateIssue(id, payload) + if err != nil { + t.Errorf("Error on Updating %v", err) + } +} + +func TestJira_UpdateFixVersions(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/2/issue/\\w+", func(w http.ResponseWriter, r *http.Request) { + if m := r.Method; m != "PUT" { + t.Errorf("Incorrect HTTP Method. Expected PUT got %v", m) + } + + if u := r.URL.String(); !strings.HasPrefix(u, "/rest/api/2/issue") { + t.Errorf("Incorrect URL. Expected /rest/api/2/issue, got %v", u) + } + + w.WriteHeader(http.StatusNoContent) + }) + tasks := []string{"test-1234", "foo-42"} + err := testClient.UpdateIssueFixVersions(tasks) + if err != nil { + t.Errorf("Error while updating fixed versions %v", err) + } +} diff --git a/repo/branch.go b/repo/branch.go new file mode 100644 index 0000000..7d39de3 --- /dev/null +++ b/repo/branch.go @@ -0,0 +1,82 @@ +package repo + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/pkg/errors" + git "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +// CurrentBranch returns the currently checked out branch +func (r *Repo) CurrentBranch() (string, error) { + cmd := fmt.Sprintf("cd %s && git rev-parse --abbrev-ref HEAD", r.path) + out, err := exec.Command("sh", "-c", cmd).Output() + if err != nil { + return "", errors.Wrap(err, "executing external command") + } + + return strings.TrimSpace(string(out)), nil +} + +// CompareBranch lists the commits ahead and behind of a targetBranch compared +// to a baseBranch. +func (r *Repo) CompareBranch(baseBranch, branch string) ([]*object.Commit, []*object.Commit, error) { + commonAncestor, err := mergeBase(baseBranch, branch, r.path) + if err != nil { + return nil, nil, errors.Wrap(err, "executing merge-base") + } + + ahead, err := commitsAhead(r.repo, branch, commonAncestor) + if err != nil { + return nil, nil, errors.Wrap(err, "comparing branches") + } + behind, err := commitsAhead(r.repo, baseBranch, commonAncestor) + if err != nil { + return nil, nil, errors.Wrap(err, "comparing branches") + } + + return ahead, behind, nil +} + +func commitsAhead(repo *git.Repository, branch string, commonAncestor string) ([]*object.Commit, error) { + reference := fmt.Sprintf("refs/remotes/origin/%s", branch) + ref, err := repo.Reference(plumbing.ReferenceName(reference), true) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("loading reference %s", reference)) + } + + cIter, err := repo.Log(&git.LogOptions{From: ref.Hash()}) + if err != nil { + return nil, errors.Wrap(err, "branch log") + } + defer cIter.Close() + + var ahead []*object.Commit + for { + commit, err := cIter.Next() + if err != nil { + return nil, errors.Wrap(err, "iterating commits") + } + + if commit.Hash.String() == commonAncestor { + break + } + ahead = append(ahead, commit) + } + + return ahead, nil +} + +func mergeBase(branch1 string, branch2 string, directory string) (string, error) { + cmd := fmt.Sprintf("cd %s && git merge-base origin/%s origin/%s", directory, branch1, branch2) + out, err := exec.Command("sh", "-c", cmd).Output() + if err != nil { + return "", errors.Wrap(err, "executing external command") + } + + return strings.TrimSpace(string(out)), nil +} diff --git a/repo/format.go b/repo/format.go new file mode 100644 index 0000000..0bdf474 --- /dev/null +++ b/repo/format.go @@ -0,0 +1,36 @@ +package repo + +import ( + "fmt" + "strings" + + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +// FormatMessage formats the commit's message +func FormatMessage(c *object.Commit, firstLinePrefix string, nextLinesPrefix string, lineSeparator string) string { + outputStrings := []string{} + maxLines := 6 + + lines := strings.Split(c.Message, "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + + var prefix string + + if len(outputStrings) == 0 { + prefix = firstLinePrefix + } else { + prefix = nextLinesPrefix + } + + outputStrings = append(outputStrings, fmt.Sprintf("%s%s", prefix, strings.TrimSpace(line))) + + if len(outputStrings) >= maxLines { + break + } + } + return strings.Join(outputStrings, lineSeparator) +} diff --git a/repo/repo.go b/repo/repo.go new file mode 100644 index 0000000..c5c03d1 --- /dev/null +++ b/repo/repo.go @@ -0,0 +1,146 @@ +package repo + +import ( + "fmt" + "strings" + + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +// Repo describes a git repository +type Repo struct { + repoURL string + path string + remoteName string + + repo *git.Repository + remote *git.Remote +} + +// DiffCommitBranch commits ahead and commits behind for a given branch and base branch +type DiffCommitBranch struct { + Branch string + BaseBranch string + Ahead []*object.Commit + Behind []*object.Commit +} + +// NewFromPath instantiates a Repo loading it from a directory on disk +func NewFromPath(path, remoteName string) (*Repo, error) { + var err error + + r := &Repo{ + path: path, + remoteName: remoteName, + } + + if r.path == "" { + return nil, fmt.Errorf("no path provided") + } + + r.repo, err = git.PlainOpen(r.path) + if err != nil { + return nil, err + } + + err = r.setGitRemote() + if err != nil { + return nil, fmt.Errorf("error set") + } + + return r, nil +} + +// NewClone instantiates a Repo loading it from a directory on disk +func NewClone(repoURL, path, remoteName string) (*Repo, error) { + var err error + + r := &Repo{ + repoURL: repoURL, + path: path, + remoteName: remoteName, + } + + if r.path == "" { + return nil, fmt.Errorf("no path to clone to") + } + if r.repoURL == "" { + return nil, fmt.Errorf("no url to clone from") + } + + r.repo, err = git.PlainClone(r.path, false, &git.CloneOptions{ + URL: r.repoURL, + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + }) + if err != nil { + return nil, err + } + + err = r.setGitRemote() + if err != nil { + return nil, fmt.Errorf("error set") + } + + return r, nil +} + +// Fetch fetches from the default remote +func (r *Repo) Fetch() error { + fmt.Printf("Fetching remote %s (use --skipFetch to skip)\n", r.remoteName) + err := r.remote.Fetch(&git.FetchOptions{}) + if err != nil { + if !strings.Contains(err.Error(), "already up-to-date") { + return fmt.Errorf("unable to fetch remote %s: %s", r.remoteName, err) + } + // simply a notice, not an error + fmt.Println(err) + } + + return nil +} + +// GitRepo exposes a git.Repository +func (r *Repo) GitRepo() *git.Repository { + return r.repo +} + +// GitRemote exposes a git.Remote +func (r *Repo) GitRemote() *git.Remote { + return r.remote +} + +func (r *Repo) setGitRemote() error { + remote, err := r.repo.Remote(r.remoteName) + if err != nil { + return fmt.Errorf("error loading remote %s:%v", r.remoteName, err) + } + + r.remote = remote + + return nil +} + +// OrganizationName default remote's organization or user +func (r *Repo) OrganizationName() string { + parts := strings.Split(r.remote.Config().URLs[0], "/") + name := parts[len(parts)-2] + // if remote is set by ssh instead of https + if strings.Contains(name, ":") { + return name[strings.LastIndex(name, ":")+1:] + } + + return name +} + +// Name the name of the repo as a suffix of the clone url (excluding .git) of the default remote +func (r *Repo) Name() string { + parts := strings.Split(r.remote.Config().URLs[0], "/") + + return strings.TrimSuffix(parts[len(parts)-1], ".git") +} + +// Path the path where the repo resides +func (r *Repo) Path() string { + return r.path +} diff --git a/repo/repo_functional_test.go b/repo/repo_functional_test.go new file mode 100644 index 0000000..b86dd9c --- /dev/null +++ b/repo/repo_functional_test.go @@ -0,0 +1,71 @@ +package repo + +import ( + "os" + "testing" + + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +func TestRepo(t *testing.T) { + var err error + var ahead, behind []*object.Commit + + path := "/tmp/ergo-functional-test-repo" + repoURL := "https://github.com/dbaltas/ergo-functional-test-repo.git" + + // cleanup after test run + defer func() { + err := os.RemoveAll(path) + if err != nil { + t.Fatalf("error cleaning up %s: %v", path, err) + } + }() + + r, err := NewClone(repoURL, path, "origin") + + if err != nil || r == nil { + t.Fatalf("Error cloning repo:%v\n", err) + } + + t.Run("Clone already cloned", func(t *testing.T) { + _, err = NewClone(repoURL, path, "origin") + + if err == nil { + t.Errorf("Expected 'repository already exists' error") + } + }) + + t.Run("Load from disk already cloned", func(t *testing.T) { + r2, err := NewFromPath(path, "origin") + if err != nil || r2 == nil { + t.Errorf("error loading repo from path: %v", err) + } + }) + + t.Run("Compare Branch", func(t *testing.T) { + targetBranch := "ft-master" + baseBranch := "ft-develop" + ahead, behind, err = r.CompareBranch(baseBranch, targetBranch) + if err != nil { + t.Errorf("error comparing branches %s %s: %v", baseBranch, targetBranch, err) + return + } + if len(ahead) != 0 { + t.Errorf("expected %s to be 0 commits ahead of %s: actual:%d", targetBranch, baseBranch, len(ahead)) + return + } + if len(behind) != 2 { + t.Errorf("expected %s to be 2 commits behind of %s: actual:%d", targetBranch, baseBranch, len(behind)) + return + } + }) + + t.Run("Format Commit", func(t *testing.T) { + commitMessage := FormatMessage(behind[0], "", "", "") + + if commitMessage != "feature-b" { + t.Errorf("expected:%s, got:%s", "feature-b", commitMessage) + } + }) +}