18093: add functionality to create a new release checklist ticket in
authorWard Vandewege <ward@curii.com>
Thu, 18 Nov 2021 01:18:00 +0000 (20:18 -0500)
committerWard Vandewege <ward@curii.com>
Thu, 18 Nov 2021 20:17:15 +0000 (15:17 -0500)
       Redmine.

Arvados-DCO-1.1-Signed-off-by: Ward Vandewege <ward@curii.com>

.licenseignore
cmd/art/TASKS [new file with mode: 0644]
cmd/art/redmine.go
go.mod
go.sum
lib/redmine/issues.go
lib/redmine/projects.go [new file with mode: 0644]
lib/redmine/redmine.go
lib/redmine/releases.go [new file with mode: 0644]
lib/redmine/version.go [new file with mode: 0644]

index ab55dbee0468c734309562daf82486f1894a89f6..cc1b258575be10a370623d6c806c6e6c3396fead 100644 (file)
@@ -6,3 +6,4 @@ jenkins/packer-images/*.json
 jenkins/packer-images/1078ECD7.asc
 go.mod
 go.sum
+cmd/art/TASKS
diff --git a/cmd/art/TASKS b/cmd/art/TASKS
new file mode 100644 (file)
index 0000000..4fdee2c
--- /dev/null
@@ -0,0 +1,19 @@
+Prepare release branch
+Review release branch
+Create next redmine release
+Record git commit
+Build RC packages
+Draft release notes
+Review release notes
+Test installer
+Deploy RC packages to playground
+Run test pipeline
+Sign off
+Push packages to stable
+Publish docker image, python and ruby packages
+Publish formula/installer for release
+Publish arvbox image
+Tag commits
+Update doc site
+Publish release on arvados.org
+Send out release notification
index 4836428bdf2e9c3b9ec96f4f7fdd46b16d2a1d18..27c14e0d2d999f907a508641b0c8a2cff3e99f6f 100644 (file)
@@ -5,15 +5,18 @@
 package main
 
 import (
+       "bufio"
        "fmt"
        "log"
        "os"
        "regexp"
        "sort"
        "strconv"
+       "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"
@@ -56,6 +59,23 @@ 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)")
        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().IntP("project", "p", 0, "Redmine project ID")
+       err = createReleaseIssueCmd.MarkFlagRequired("project")
+       if err != nil {
+               log.Fatalf(err.Error())
+       }
+       issuesCmd.AddCommand(createReleaseIssueCmd)
 }
 
 var redmineCmd = &cobra.Command{
@@ -254,13 +274,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 +303,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 +326,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 +338,114 @@ 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
+               }
+               projectID, err := cmd.Flags().GetInt("project")
+               if err != nil {
+                       log.Fatal(fmt.Errorf("[error] can not convert Redmine project ID to integer: %s", err))
+                       return
+               }
+
+               r := redmine.NewClient(conf.Endpoint, conf.Apikey)
+
+               // Does this project exist?
+               project, err := r.GetProject(projectID)
+               if err != nil {
+                       log.Fatalf("[error] can not find project with id %d: %s", projectID, 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, projectID)
+               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 := 0
+               for scanner.Scan() {
+                       task := scanner.Text()
+                       taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, projectID)
+                       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 = projectID
+                       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}
+
+                       tmpRelease, err := r.CreateRelease(*release)
+                       if err != nil {
+                               log.Fatalf("Unable to create release: %s", err)
+                       }
+                       release = tmpRelease
+               }
+               fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
+       },
+}
diff --git a/go.mod b/go.mod
index bf39b750b3bff0cb2d122b31e5e862cf1ffe1e99..af7ce06fe860b7f371a284d2661a883abea1a7bb 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.17
 
 require (
        github.com/AlecAivazis/survey/v2 v2.3.2
+       github.com/Masterminds/semver v1.5.0
        github.com/go-git/go-git/v5 v5.4.2
        github.com/spf13/cobra v1.2.1
        github.com/spf13/viper v1.9.0
diff --git a/go.sum b/go.sum
index e37a34927fd495d78193da354bd41201027d8eb8..a4f235376aed7e508f40cc1569696c706f5239dc 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -47,6 +47,8 @@ github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO
 github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
 github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
@@ -147,7 +149,6 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
index 19f4c3e7c2a6e0e98cfcff0e5a17ffc098f5bae9..e16eeba409901aee4f3ed0759bd277b892f55ed8 100644 (file)
@@ -118,14 +118,14 @@ func (c *Client) CreateIssue(issue Issue) (*Issue, error) {
        if err != nil {
                return nil, err
        }
-       res, err := c.Put("/issues.json", string(s))
+       res, err := c.Post("/issues.json", string(s))
        if err != nil {
                return nil, err
        }
        defer res.Body.Close()
 
        var r issueWrapper
-       err = responseHelper(res, &r, 200)
+       err = responseHelper(res, &r, 201)
        if err != nil {
                return nil, err
        }
@@ -198,10 +198,13 @@ func (c *Client) FindOrCreateIssue(subject string, parentID int, versionID int,
        issue.FixedVersionID = versionID
        issue.Subject = subject
        if parentID != 0 {
-               issue.Parent = &ID{ID: parentID}
+               issue.ParentIssueID = parentID
        }
 
        i, err := c.CreateIssue(issue)
+       if err != nil {
+               return Issue{}, err
+       }
        return *i, err
 }
 
diff --git a/lib/redmine/projects.go b/lib/redmine/projects.go
new file mode 100644 (file)
index 0000000..f942c13
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Somewhat inspired by https://github.com/mattn/go-redmine (MIT licensed)
+
+package redmine
+
+import (
+       "strconv"
+)
+
+type projectWrapper struct {
+       Project Project `json:"project"`
+}
+
+type projectsResult struct {
+       Projects []Project `json:"projects"`
+}
+
+type Project struct {
+       ID          int    `json:"id"`
+       Parent      IDName `json:"parent"`
+       Name        string `json:"name"`
+       IDentifier  string `json:"identifier"`
+       Description string `json:"description"`
+       CreatedOn   string `json:"created_on"`
+       UpdatedOn   string `json:"updated_on"`
+}
+
+func (c *Client) GetProject(id int) (*Project, error) {
+       res, err := c.Get("/projects/" + strconv.Itoa(id) + ".json")
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       var r projectWrapper
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return &r.Project, nil
+}
index c8a2677da90a9329963f2e8e012f7a370ef13b90..70a93f4a89a9aaa6fabb727ba68957c3ded29604 100644 (file)
@@ -50,6 +50,20 @@ func (c *Client) Get(url string) (*http.Response, error) {
        return res, err
 }
 
+func (c *Client) Post(url string, payload string) (*http.Response, error) {
+       req, err := http.NewRequest("POST", c.endpoint+url, strings.NewReader(payload))
+       if err != nil {
+               return nil, err
+       }
+       req.Header.Set("Content-Type", "application/json")
+       req.Header.Add("X-Redmine-API-Key", c.apikey)
+       res, err := c.Do(req)
+       if err != nil {
+               return nil, err
+       }
+       return res, err
+}
+
 func (c *Client) Put(url string, payload string) (*http.Response, error) {
        req, err := http.NewRequest("PUT", c.endpoint+url, strings.NewReader(payload))
        if err != nil {
diff --git a/lib/redmine/releases.go b/lib/redmine/releases.go
new file mode 100644 (file)
index 0000000..4a13e3f
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package redmine
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "strings"
+)
+
+type Release struct {
+       ID               int     `json:"id"`
+       Name             string  `json:"name"`
+       Description      string  `json:"description"`
+       Sharing          string  `json:"sharing"`
+       ReleaseStartDate string  `json:"release_start_date"`
+       ReleaseEndDate   string  `json:"release_end_date"`
+       PlannedVelocity  string  `json:"planned_velocity"`
+       Status           string  `json:"status"`
+       ProjectID        int     `json:"-"`
+       Project          *IDName `json:"-"`
+}
+
+type releaseWrapper struct {
+       Release Release `json:"release"`
+}
+
+// FindReleaseByName retrieves a redmine Release object by name
+func (c *Client) FindReleaseByName(project, name string) (*Release, error) {
+       // This api call only returns the first matching release object. There is no unique index on release names.
+       res, err := c.Get("/rb/release/" + strings.ToLower(project) + "/find_by_name.json?name=" + url.QueryEscape(name))
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       if res.StatusCode == 404 {
+               return nil, fmt.Errorf("Missing API call /rb/release/project_id/find_by_name.json")
+       }
+       var r releaseWrapper
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       if r.Release.ID == 0 {
+               return nil, nil
+       }
+       return &r.Release, nil
+}
+
+func (c *Client) CreateRelease(release Release) (*Release, error) {
+       var rr releaseWrapper
+       rr.Release = release
+       s, err := json.Marshal(rr)
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.Post("/rb/release/"+strings.ToLower(release.Project.Name)+"/new.json", string(s))
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       var r releaseWrapper
+       err = responseHelper(res, r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return &r.Release, nil
+}
diff --git a/lib/redmine/version.go b/lib/redmine/version.go
new file mode 100644 (file)
index 0000000..9135dc0
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package redmine
+
+import (
+       //      "encoding/json"
+       "errors"
+       "strconv"
+       //      "strings"
+)
+
+type versionWrapper struct {
+       Version Version `json:"version"`
+}
+
+type versionsResult struct {
+       Versions []Version `json:"versions"`
+}
+
+type Version struct {
+       ID          int    `json:"id"`
+       Project     IDName `json:"project"`
+       Name        string `json:"name"`
+       Description string `json:"description"`
+       Status      string `json:"status"`
+       DueDate     string `json:"due_date"`
+       CreatedOn   string `json:"created_on"`
+       UpdatedOn   string `json:"updated_on"`
+}
+
+func (c *Client) Version(id int) (*Version, error) {
+       res, err := c.Get("/versions/" + strconv.Itoa(id) + ".json")
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       if res.StatusCode == 404 {
+               return nil, errors.New("Not Found")
+       }
+
+       var r versionWrapper
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return &r.Version, nil
+       /*
+               decoder := json.NewDecoder(res.Body)
+               var r versionWrapper
+               if res.StatusCode != 200 {
+                       var er errorsResult
+                       err = decoder.Decode(&er)
+                       if err == nil {
+                               err = errors.New(strings.Join(er.Errors, "\n"))
+                       }
+               } else {
+                       err = decoder.Decode(&r)
+               }
+               if err != nil {
+                       return nil, err
+               } */
+       return &r.Version, nil
+}