Add set-sprint subcommand to 'art', no issue #
[arvados-dev.git] / cmd / art / redmine.go
index 4836428bdf2e9c3b9ec96f4f7fdd46b16d2a1d18..a170a6ecf609f0e5fdc798171e64c3c7d73880a1 100644 (file)
@@ -5,15 +5,20 @@
 package main
 
 import (
+       "bufio"
+       "encoding/json"
        "fmt"
        "log"
        "os"
        "regexp"
        "sort"
        "strconv"
+       "sync"
+       "time"
 
        "git.arvados.org/arvados-dev.git/lib/redmine"
        survey "github.com/AlecAivazis/survey/v2"
+       "github.com/Masterminds/semver"
        "github.com/go-git/go-git/v5"
        "github.com/go-git/go-git/v5/plumbing"
        "github.com/go-git/go-git/v5/plumbing/object"
@@ -25,6 +30,7 @@ import (
 func init() {
        rootCmd.AddCommand(redmineCmd)
        redmineCmd.AddCommand(issuesCmd)
+       redmineCmd.AddCommand(releasesCmd)
 
        associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
        err := associateIssueCmd.MarkFlagRequired("release")
@@ -38,6 +44,32 @@ func init() {
        }
        issuesCmd.AddCommand(associateIssueCmd)
 
+
+       setIssueSprintCmd.Flags().IntP("sprint", "r", 0, "Redmine sprint ID")
+       err = setIssueSprintCmd.MarkFlagRequired("sprint")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       setIssueSprintCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
+       err = setIssueSprintCmd.MarkFlagRequired("issue")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       issuesCmd.AddCommand(setIssueSprintCmd)
+
+       associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
+       err = associateOrphans.MarkFlagRequired("release")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
+       err = associateOrphans.MarkFlagRequired("project")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
+       issuesCmd.AddCommand(associateOrphans)
+
        findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
        err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
        if err != nil {
@@ -55,7 +87,36 @@ func init() {
        }
        findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
        findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
+       findAndAssociateIssuesCmd.Flags().StringP("source-repo", "", "https://github.com/arvados/arvados.git", "Source repository to clone from")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+
        issuesCmd.AddCommand(findAndAssociateIssuesCmd)
+
+       createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
+       err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
+       err = createReleaseIssueCmd.MarkFlagRequired("sprint")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
+       err = createReleaseIssueCmd.MarkFlagRequired("project")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       issuesCmd.AddCommand(createReleaseIssueCmd)
+
+       getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
+       err = getReleaseCmd.MarkFlagRequired("release")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       releasesCmd.AddCommand(getReleaseCmd)
 }
 
 var redmineCmd = &cobra.Command{
@@ -89,6 +150,134 @@ var issuesCmd = &cobra.Command{
                "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
 }
 
+var associateOrphans = &cobra.Command{
+       Use:   "associate-orphans", // FIXME
+       Short: "Find open issues without a release and version, assign them to the given release",
+       Long: "Find open issues without a release and version, assign them to the given release.\n" +
+               "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
+               "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
+       Run: func(cmd *cobra.Command, args []string) {
+               rID, err := cmd.Flags().GetInt("release")
+               if err != nil {
+                       fmt.Printf("Error converting Redmine release ID to integer: %s", err)
+                       os.Exit(1)
+               }
+               pName, err := cmd.Flags().GetString("project")
+               if err != nil {
+                       log.Fatalf("Error getting the requested project name: %s", err)
+               }
+               dryRun, err := cmd.Flags().GetBool("dry-run")
+               if err != nil {
+                       log.Fatalf("Error getting the dry-run parameter")
+               }
+
+               rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
+               p, err := rm.GetProjectByName(pName)
+               if err != nil {
+                       log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
+               }
+               r, err := rm.GetRelease(rID)
+               if err != nil {
+                       log.Fatalf("Error retrieving release '%d': %s", rID, err)
+               }
+               flt := redmine.IssueFilter{
+                       StatusID:  "open",
+                       ProjectID: fmt.Sprintf("%d", p.ID),
+                       // No values assigned on the following fields. It seems that using
+                       // an empty string is interpreted as 'any value'. The documentation
+                       // isn't clear, but after some trial & error, '!*' seems to do the trick.
+                       // https://www.redmine.org/projects/redmine/wiki/Rest_Issues
+                       ReleaseID: "!*",
+                       VersionID: "!*",
+                       ParentID:  "!*",
+               }
+               issues, err := rm.FilteredIssues(&flt)
+               if err != nil {
+                       fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
+               }
+               fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
+
+               type job struct {
+                       issue  redmine.Issue
+                       rID    int
+                       dryRun bool
+               }
+               type result struct {
+                       msg     string
+                       success bool
+               }
+               var wg sync.WaitGroup
+               jobs := make(chan job, len(issues))
+               results := make(chan result, len(issues))
+
+               worker := func(id int, jobs <-chan job, results chan<- result) {
+                       for j := range jobs {
+                               msg := fmt.Sprintf("#%d - %s ", j.issue.ID, j.issue.Subject)
+                               success := true
+                               if !j.dryRun {
+                                       err = rm.SetRelease(j.issue, j.rID)
+                                       if err != nil {
+                                               success = false
+                                               msg = fmt.Sprintf("%s [error] (%s)\n", msg, err)
+                                       } else {
+                                               msg = fmt.Sprintf("%s [changed]\n", msg)
+                                       }
+                               } else {
+                                       msg = fmt.Sprintf("%s [skipped]\n", msg)
+                               }
+                               results <- result{
+                                       msg:     msg,
+                                       success: success,
+                               }
+                       }
+               }
+
+               wn := 8
+               if len(issues) < wn {
+                       wn = len(issues)
+               }
+               for w := 1; w <= wn; w++ {
+                       wg.Add(1)
+                       w := w
+                       go func() {
+                               defer wg.Done()
+                               worker(w, jobs, results)
+                       }()
+               }
+
+               for _, issue := range issues {
+                       jobs <- job{
+                               issue:  issue,
+                               rID:    rID,
+                               dryRun: dryRun,
+                       }
+               }
+               close(jobs)
+
+               succeded := true
+               errCount := 0
+               var wg2 sync.WaitGroup
+               wg2.Add(1)
+               go func() {
+                       defer wg2.Done()
+                       for r := range results {
+                               fmt.Printf(r.msg)
+                               if !r.success {
+                                       succeded = false
+                                       errCount += 1
+                               }
+                       }
+               }()
+
+               wg.Wait()
+               close(results)
+               wg2.Wait()
+               if !succeded {
+                       log.Fatalf("Warning: %d error(s) found.", errCount)
+               }
+       },
+}
+
 var associateIssueCmd = &cobra.Command{
        Use:   "associate",
        Short: "Associate an issue with a release",
@@ -136,6 +325,54 @@ var associateIssueCmd = &cobra.Command{
        },
 }
 
+
+var setIssueSprintCmd = &cobra.Command{
+       Use:   "set-sprint",
+       Short: "Set sprint for issue",
+       Long: "Set the sprint for an issue.\n" +
+               "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
+               "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
+       Run: func(cmd *cobra.Command, args []string) {
+               issueID, err := cmd.Flags().GetInt("issue")
+               if err != nil {
+                       fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
+                       os.Exit(1)
+               }
+
+               sprintID, err := cmd.Flags().GetInt("sprint")
+               if err != nil {
+                       fmt.Printf("Error converting Redmine sprint ID to integer: %s", err)
+                       os.Exit(1)
+               }
+
+               redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
+
+               i, err := redmine.GetIssue(issueID)
+               if err != nil {
+                       fmt.Printf("%s\n", err.Error())
+                       os.Exit(1)
+               }
+
+               var setIt bool
+               if i.FixedVersion == nil {
+                       setIt = true
+               } else if i.FixedVersion.ID != sprintID {
+                       setIt = true
+               }
+               if setIt {
+                       err = redmine.SetSprint(*i, sprintID)
+                       if err != nil {
+                               fmt.Printf("%s\n", err.Error())
+                               os.Exit(1)
+                       } else {
+                               fmt.Printf("[changed] sprint for issue %d set to %d\n", i.ID, sprintID)
+                       }
+               } else {
+                       fmt.Printf("[ok] sprint for issue %d was already set to %d, not updating\n", i.ID, i.FixedVersion.ID)
+               }
+       },
+}
+
 func checkError(err error) {
        if err != nil {
                fmt.Printf("%s\n", err.Error())
@@ -143,6 +380,13 @@ func checkError(err error) {
        }
 }
 
+func checkError2(msg string, err error) {
+       if err != nil {
+               fmt.Printf("%s: %s\n", msg, err.Error())
+               os.Exit(1)
+       }
+}
+
 var findAndAssociateIssuesCmd = &cobra.Command{
        Use:   "find-and-associate",
        Short: "Find all issue numbers to associate with a release, and associate them",
@@ -177,6 +421,11 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                        log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
                        return
                }
+               arvRepo, err := cmd.Flags().GetString("source-repo")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
+                       return
+               }
 
                if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
                        log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
@@ -188,15 +437,20 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                }
 
                // Clone the repo in memory
-               fmt.Println("Cloning https://github.com/arvados/arvados.git")
+
+               // our own arvados repo won't clone,
+               //arvRepo := "https://git.arvados.org/arvados.git"
+               //arvRepo := "https://github.com/arvados/arvados.git"
+
+               fmt.Println("Cloning " + arvRepo)
                repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
-                       URL: "https://github.com/arvados/arvados.git",
+                       URL: arvRepo,
                })
                checkError(err)
                fmt.Println("... done")
                fmt.Println()
                start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
-               checkError(err)
+               checkError2("repo.ResolveRevision", err)
                fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
                fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
                fmt.Println()
@@ -204,7 +458,7 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                // Build the exclusion list
                seen := make(map[plumbing.Hash]bool)
                excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
-               checkError(err)
+               checkError2("repo.Log", err)
                excludeIter.ForEach(func(c *object.Commit) error {
                        seen[c.Hash] = true
                        return nil
@@ -219,7 +473,7 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                }
 
                headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
-               checkError(err)
+               checkError2("repo.CommitObject", err)
 
                iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
 
@@ -254,13 +508,13 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                }
                sort.Ints(keys)
 
-               redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
+               r := redmine.NewClient(conf.Endpoint, conf.Apikey)
 
                for c, k := range keys {
                        fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
                        // Look up the issue, see if it is already associated with the desired release
 
-                       i, err := redmine.GetIssue(k)
+                       i, err := r.GetIssue(k)
                        if err != nil {
                                fmt.Println()
                                fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
@@ -283,7 +537,7 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                                                log.Fatal(err)
                                        }
                                        if confirm {
-                                               err = redmine.SetRelease(*i, releaseID)
+                                               err = r.SetRelease(*i, releaseID)
                                                if err != nil {
                                                        log.Fatal(err)
                                                } else {
@@ -306,7 +560,7 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                                        }
                                }
                                if confirm || autoSet {
-                                       err = redmine.SetRelease(*i, releaseID)
+                                       err = r.SetRelease(*i, releaseID)
                                        if err != nil {
                                                log.Fatal(err)
                                        } else {
@@ -318,3 +572,149 @@ var findAndAssociateIssuesCmd = &cobra.Command{
                }
        },
 }
+
+var createReleaseIssueCmd = &cobra.Command{
+       Use:   "create-release-issue",
+       Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
+       Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
+               "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
+               "\nFinally, a new Redmine release will also be created for the next release.\n" +
+               "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
+               "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
+       Run: func(cmd *cobra.Command, args []string) {
+               newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
+                       return
+               }
+
+               versionID, err := cmd.Flags().GetInt("sprint")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
+                       return
+               }
+               projectName, err := cmd.Flags().GetString("project")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
+                       return
+               }
+
+               r := redmine.NewClient(conf.Endpoint, conf.Apikey)
+
+               // Does this project exist?
+               project, err := r.GetProjectByName(projectName)
+               if err != nil {
+                       log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
+               }
+
+               // Is the sprint (aka "version" in redmine) in the correct state?
+               v, err := r.Version(versionID)
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
+               }
+               if v.Status != "open" {
+                       log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
+               }
+
+               i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
+               if err != nil {
+                       log.Fatal(err)
+               }
+               if i.Status.Name != "New" {
+                       log.Fatal(fmt.Errorf("the release ticket status must be 'New'; the status of the release issue with id %d is '%s'", i.ID, v.Status))
+               }
+
+               fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
+
+               // Get the list of subtasks from the "TASKS" file
+               tasks, err := os.Open("TASKS")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
+               }
+               defer tasks.Close()
+
+               scanner := bufio.NewScanner(tasks)
+               count := 1
+               for scanner.Scan() {
+                       task := scanner.Text()
+                       taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
+                       fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
+                       count++
+                       if err != nil {
+                               log.Fatal(fmt.Errorf("Error reading from file: %s", err))
+                       }
+               }
+
+               // Create the next release in Redmine
+               version, err := semver.NewVersion(newReleaseVersion)
+               if err != nil {
+                       log.Fatalf("Error parsing version: %s", err)
+               }
+               nextVersion := version.IncPatch()
+
+               var release *redmine.Release
+
+               release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
+               if err != nil {
+                       log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
+               }
+               if release == nil {
+                       // No release found, create it
+                       release = &redmine.Release{}
+                       release.Name = "Arvados " + nextVersion.String()
+                       release.Sharing = "hierarchy"
+                       release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
+                       release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02")   // also arbitrary, 5 weeks from today
+                       release.ProjectID = project.ID
+                       release.Status = "open"
+                       // Populate Project
+                       tmp, err := r.GetProject(release.ProjectID)
+                       if err != nil {
+                               log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
+                       }
+                       release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
+
+                       release, err = r.CreateRelease(*release)
+                       if err != nil {
+                               log.Fatalf("Unable to create release: %s", err)
+                       }
+               }
+               fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
+       },
+}
+
+var releasesCmd = &cobra.Command{
+       Use:   "releases",
+       Short: "Manage Redmine releases",
+       Long: "Manage Redmine releases.\n" +
+               "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
+               "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
+}
+
+var getReleaseCmd = &cobra.Command{
+       Use:   "get",
+       Short: "get a release",
+       Long: "Get a release.\n" +
+               "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
+               "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
+       Run: func(cmd *cobra.Command, args []string) {
+               releaseID, err := cmd.Flags().GetInt("release")
+               if err != nil {
+                       fmt.Printf("Error converting Redmine release ID to integer: %s", err)
+                       os.Exit(1)
+               }
+
+               r := redmine.NewClient(conf.Endpoint, conf.Apikey)
+
+               release, err := r.GetRelease(releaseID)
+               if err != nil {
+                       log.Fatalf("Error finding release with id %d: %s", releaseID, err)
+               }
+               releaseStr, err := json.MarshalIndent(release, "", "  ")
+               if err != nil {
+                       log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
+               }
+               fmt.Println(string(releaseStr))
+
+       },
+}