1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: Apache-2.0
18 "git.arvados.org/arvados-dev.git/lib/redmine"
19 survey "github.com/AlecAivazis/survey/v2"
20 "github.com/Masterminds/semver"
21 "github.com/go-git/go-git/v5"
22 "github.com/go-git/go-git/v5/plumbing"
23 "github.com/go-git/go-git/v5/plumbing/object"
24 "github.com/go-git/go-git/v5/plumbing/storer"
25 "github.com/go-git/go-git/v5/storage/memory"
26 "github.com/spf13/cobra"
30 rootCmd.AddCommand(redmineCmd)
31 redmineCmd.AddCommand(issuesCmd)
32 redmineCmd.AddCommand(releasesCmd)
34 associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
35 err := associateIssueCmd.MarkFlagRequired("release")
37 log.Fatalf(err.Error())
39 associateIssueCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
40 err = associateIssueCmd.MarkFlagRequired("issue")
42 log.Fatalf(err.Error())
44 issuesCmd.AddCommand(associateIssueCmd)
46 findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
47 err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
49 log.Fatalf(err.Error())
51 findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
52 err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
54 log.Fatalf(err.Error())
56 findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
57 err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
59 log.Fatalf(err.Error())
61 findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
62 findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
63 issuesCmd.AddCommand(findAndAssociateIssuesCmd)
65 createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
66 err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
68 log.Fatalf(err.Error())
70 createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
71 err = createReleaseIssueCmd.MarkFlagRequired("sprint")
73 log.Fatalf(err.Error())
75 createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
76 err = createReleaseIssueCmd.MarkFlagRequired("project")
78 log.Fatalf(err.Error())
80 issuesCmd.AddCommand(createReleaseIssueCmd)
82 getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
83 err = getReleaseCmd.MarkFlagRequired("release")
85 log.Fatalf(err.Error())
87 releasesCmd.AddCommand(getReleaseCmd)
90 var redmineCmd = &cobra.Command{
92 Short: "Manage Redmine",
93 Long: "Manage Redmine.\n" +
94 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
95 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
96 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
97 if conf.Endpoint == "" {
100 fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
103 if conf.Apikey == "" {
106 fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
113 var issuesCmd = &cobra.Command{
115 Short: "Manage Redmine issues",
116 Long: "Manage Redmine issues.\n" +
117 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
118 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
121 var associateIssueCmd = &cobra.Command{
123 Short: "Associate an issue with a release",
124 Long: "Associate an issue with a release.\n" +
125 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
126 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
127 Run: func(cmd *cobra.Command, args []string) {
128 issueID, err := cmd.Flags().GetInt("issue")
130 fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
134 releaseID, err := cmd.Flags().GetInt("release")
136 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
140 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
142 i, err := redmine.GetIssue(issueID)
144 fmt.Printf("%s\n", err.Error())
149 if i.Release == nil || i.Release["release"].ID == 0 {
151 } else if i.Release["release"].ID != releaseID {
155 err = redmine.SetRelease(*i, releaseID)
157 fmt.Printf("%s\n", err.Error())
160 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
163 fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
168 func checkError(err error) {
170 fmt.Printf("%s\n", err.Error())
175 var findAndAssociateIssuesCmd = &cobra.Command{
176 Use: "find-and-associate",
177 Short: "Find all issue numbers to associate with a release, and associate them",
178 Long: "Find all issue numbers to associate with a release, and associate them.\n" +
179 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
180 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
181 Run: func(cmd *cobra.Command, args []string) {
182 previousReleaseTag, err := cmd.Flags().GetString("previous-release-tag")
184 log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
188 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
190 log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
193 releaseID, err := cmd.Flags().GetInt("release")
195 log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
199 autoSet, err := cmd.Flags().GetBool("auto-set")
201 log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
204 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
206 log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
210 if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
211 log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
214 if len(newReleaseCommitHash) != 7 && len(newReleaseCommitHash) != 40 {
215 log.Fatal(fmt.Errorf("The new-release-commit argument is of an unexpected format. Expecting a git commit hash (7 or 40 digits long)"))
219 // Clone the repo in memory
220 fmt.Println("Cloning https://github.com/arvados/arvados.git")
221 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
222 URL: "https://github.com/arvados/arvados.git",
225 fmt.Println("... done")
227 start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
229 fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
230 fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
233 // Build the exclusion list
234 seen := make(map[plumbing.Hash]bool)
235 excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
237 excludeIter.ForEach(func(c *object.Commit) error {
242 // isValid returns merge commits that are not in the exclusion list
243 var isValid object.CommitFilter = func(commit *object.Commit) bool {
244 _, ok := seen[commit.Hash]
246 // use len(commit.ParentHashes) to only get merge commits
247 return !ok && len(commit.ParentHashes) >= 2
250 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
253 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
255 issues := make(map[int]bool)
256 re := regexp.MustCompile(`Merge branch `)
257 reNotMain := regexp.MustCompile(`Merge branch .(main|master)`)
258 reIssueRef := regexp.MustCompile(`(Closes|closes|Refs|refs|Fixes|fixes) #(\d+)`)
259 err = iter.ForEach(func(c *object.Commit) error {
260 // We have a git commit hook that requires an issue reference on merge commits
261 if re.MatchString(c.Message) && !reNotMain.MatchString(c.Message) {
262 m := reIssueRef.FindStringSubmatch(c.Message)
264 i, err := strconv.Atoi(m[2])
272 if c.Hash == *start {
273 return storer.ErrStop
279 // Sort the issue map keys
280 keys := make([]int, 0, len(issues))
281 for k := range issues {
282 keys = append(keys, k)
286 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
288 for c, k := range keys {
289 fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
290 // Look up the issue, see if it is already associated with the desired release
292 i, err := r.GetIssue(k)
295 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
296 fmt.Println("============================================")
299 fmt.Println(i.Subject)
301 if i.Release != nil && i.Release["release"].ID != 0 {
302 if i.Release["release"].ID == releaseID {
303 fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
304 } else if !skipReleaseChange {
305 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
307 prompt := &survey.Confirm{
308 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
310 err = survey.AskOne(prompt, &confirm)
315 err = r.SetRelease(*i, releaseID)
319 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
323 fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
326 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
329 prompt := &survey.Confirm{
330 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
332 err = survey.AskOne(prompt, &confirm)
337 if confirm || autoSet {
338 err = r.SetRelease(*i, releaseID)
342 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
346 fmt.Println("============================================")
351 var createReleaseIssueCmd = &cobra.Command{
352 Use: "create-release-issue",
353 Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
354 Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
355 "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
356 "\nFinally, a new Redmine release will also be created for the next release.\n" +
357 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
358 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
359 Run: func(cmd *cobra.Command, args []string) {
360 newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
362 log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
366 versionID, err := cmd.Flags().GetInt("sprint")
368 log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
371 projectName, err := cmd.Flags().GetString("project")
373 log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
377 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
379 // Does this project exist?
380 project, err := r.GetProjectByName(projectName)
382 log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
385 // Is the sprint (aka "version" in redmine) in the correct state?
386 v, err := r.Version(versionID)
388 log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
390 if v.Status != "open" {
391 log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
394 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
398 if i.Status.Name != "New" {
399 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))
402 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
404 // Get the list of subtasks from the "TASKS" file
405 tasks, err := os.Open("TASKS")
407 log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
411 scanner := bufio.NewScanner(tasks)
414 task := scanner.Text()
415 taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
416 fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
419 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
423 // Create the next release in Redmine
424 version, err := semver.NewVersion(newReleaseVersion)
426 log.Fatalf("Error parsing version: %s", err)
428 nextVersion := version.IncPatch()
430 var release *redmine.Release
432 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
434 log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
437 // No release found, create it
438 release = &redmine.Release{}
439 release.Name = "Arvados " + nextVersion.String()
440 release.Sharing = "hierarchy"
441 release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
442 release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02") // also arbitrary, 5 weeks from today
443 release.ProjectID = project.ID
444 release.Status = "open"
446 tmp, err := r.GetProject(release.ProjectID)
448 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
450 release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
452 release, err = r.CreateRelease(*release)
454 log.Fatalf("Unable to create release: %s", err)
457 fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
461 var releasesCmd = &cobra.Command{
463 Short: "Manage Redmine releases",
464 Long: "Manage Redmine releases.\n" +
465 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
466 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
469 var getReleaseCmd = &cobra.Command{
471 Short: "get a release",
472 Long: "Get a release.\n" +
473 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
474 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
475 Run: func(cmd *cobra.Command, args []string) {
476 releaseID, err := cmd.Flags().GetInt("release")
478 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
482 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
484 release, err := r.GetRelease(releaseID)
486 log.Fatalf("Error finding release with id %d: %s", releaseID, err)
488 releaseStr, err := json.MarshalIndent(release, "", " ")
490 log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
492 fmt.Println(string(releaseStr))