1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: Apache-2.0
19 "git.arvados.org/arvados-dev.git/lib/redmine"
20 survey "github.com/AlecAivazis/survey/v2"
21 "github.com/Masterminds/semver"
22 "github.com/go-git/go-git/v5"
23 "github.com/go-git/go-git/v5/plumbing"
24 "github.com/go-git/go-git/v5/plumbing/object"
25 "github.com/go-git/go-git/v5/plumbing/storer"
26 "github.com/go-git/go-git/v5/storage/memory"
27 "github.com/spf13/cobra"
31 rootCmd.AddCommand(redmineCmd)
32 redmineCmd.AddCommand(issuesCmd)
33 redmineCmd.AddCommand(releasesCmd)
35 associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
36 err := associateIssueCmd.MarkFlagRequired("release")
38 log.Fatalf(err.Error())
40 associateIssueCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
41 err = associateIssueCmd.MarkFlagRequired("issue")
43 log.Fatalf(err.Error())
45 issuesCmd.AddCommand(associateIssueCmd)
48 setIssueSprintCmd.Flags().IntP("sprint", "r", 0, "Redmine sprint ID")
49 err = setIssueSprintCmd.MarkFlagRequired("sprint")
51 log.Fatalf(err.Error())
53 setIssueSprintCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
54 err = setIssueSprintCmd.MarkFlagRequired("issue")
56 log.Fatalf(err.Error())
58 issuesCmd.AddCommand(setIssueSprintCmd)
60 associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
61 err = associateOrphans.MarkFlagRequired("release")
63 log.Fatalf(err.Error())
65 associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
66 err = associateOrphans.MarkFlagRequired("project")
68 log.Fatalf(err.Error())
70 associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
71 issuesCmd.AddCommand(associateOrphans)
73 findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
74 err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
76 log.Fatalf(err.Error())
78 findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
79 err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
81 log.Fatalf(err.Error())
83 findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
84 err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
86 log.Fatalf(err.Error())
88 findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
89 findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
90 findAndAssociateIssuesCmd.Flags().StringP("source-repo", "", "https://github.com/arvados/arvados.git", "Source repository to clone from")
92 log.Fatalf(err.Error())
95 issuesCmd.AddCommand(findAndAssociateIssuesCmd)
97 createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
98 err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
100 log.Fatalf(err.Error())
102 createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
103 err = createReleaseIssueCmd.MarkFlagRequired("sprint")
105 log.Fatalf(err.Error())
107 createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
108 err = createReleaseIssueCmd.MarkFlagRequired("project")
110 log.Fatalf(err.Error())
112 issuesCmd.AddCommand(createReleaseIssueCmd)
114 getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
115 err = getReleaseCmd.MarkFlagRequired("release")
117 log.Fatalf(err.Error())
119 releasesCmd.AddCommand(getReleaseCmd)
122 var redmineCmd = &cobra.Command{
124 Short: "Manage Redmine",
125 Long: "Manage Redmine.\n" +
126 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
127 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
128 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
129 if conf.Endpoint == "" {
132 fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
135 if conf.Apikey == "" {
138 fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
145 var issuesCmd = &cobra.Command{
147 Short: "Manage Redmine issues",
148 Long: "Manage Redmine issues.\n" +
149 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
150 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
153 var associateOrphans = &cobra.Command{
154 Use: "associate-orphans", // FIXME
155 Short: "Find open issues without a release and version, assign them to the given release",
156 Long: "Find open issues without a release and version, assign them to the given release.\n" +
157 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
158 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
159 Run: func(cmd *cobra.Command, args []string) {
160 rID, err := cmd.Flags().GetInt("release")
162 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
165 pName, err := cmd.Flags().GetString("project")
167 log.Fatalf("Error getting the requested project name: %s", err)
169 dryRun, err := cmd.Flags().GetBool("dry-run")
171 log.Fatalf("Error getting the dry-run parameter")
174 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
175 p, err := rm.GetProjectByName(pName)
177 log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
179 r, err := rm.GetRelease(rID)
181 log.Fatalf("Error retrieving release '%d': %s", rID, err)
183 flt := redmine.IssueFilter{
185 ProjectID: fmt.Sprintf("%d", p.ID),
186 // No values assigned on the following fields. It seems that using
187 // an empty string is interpreted as 'any value'. The documentation
188 // isn't clear, but after some trial & error, '!*' seems to do the trick.
189 // https://www.redmine.org/projects/redmine/wiki/Rest_Issues
194 issues, err := rm.FilteredIssues(&flt)
196 fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
198 fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
209 var wg sync.WaitGroup
210 jobs := make(chan job, len(issues))
211 results := make(chan result, len(issues))
213 worker := func(id int, jobs <-chan job, results chan<- result) {
214 for j := range jobs {
215 msg := fmt.Sprintf("#%d - %s ", j.issue.ID, j.issue.Subject)
218 err = rm.SetRelease(j.issue, j.rID)
221 msg = fmt.Sprintf("%s [error] (%s)\n", msg, err)
223 msg = fmt.Sprintf("%s [changed]\n", msg)
226 msg = fmt.Sprintf("%s [skipped]\n", msg)
236 if len(issues) < wn {
239 for w := 1; w <= wn; w++ {
244 worker(w, jobs, results)
248 for _, issue := range issues {
259 var wg2 sync.WaitGroup
263 for r := range results {
276 log.Fatalf("Warning: %d error(s) found.", errCount)
281 var associateIssueCmd = &cobra.Command{
283 Short: "Associate an issue with a release",
284 Long: "Associate an issue with a release.\n" +
285 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
286 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
287 Run: func(cmd *cobra.Command, args []string) {
288 issueID, err := cmd.Flags().GetInt("issue")
290 fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
294 releaseID, err := cmd.Flags().GetInt("release")
296 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
300 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
302 i, err := redmine.GetIssue(issueID)
304 fmt.Printf("%s\n", err.Error())
309 if i.Release == nil || i.Release["release"].ID == 0 {
311 } else if i.Release["release"].ID != releaseID {
315 err = redmine.SetRelease(*i, releaseID)
317 fmt.Printf("%s\n", err.Error())
320 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
323 fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
329 var setIssueSprintCmd = &cobra.Command{
331 Short: "Set sprint for issue",
332 Long: "Set the sprint for an issue.\n" +
333 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
334 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
335 Run: func(cmd *cobra.Command, args []string) {
336 issueID, err := cmd.Flags().GetInt("issue")
338 fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
342 sprintID, err := cmd.Flags().GetInt("sprint")
344 fmt.Printf("Error converting Redmine sprint ID to integer: %s", err)
348 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
350 i, err := redmine.GetIssue(issueID)
352 fmt.Printf("%s\n", err.Error())
357 if i.FixedVersion == nil {
359 } else if i.FixedVersion.ID != sprintID {
363 err = redmine.SetSprint(*i, sprintID)
365 fmt.Printf("%s\n", err.Error())
368 fmt.Printf("[changed] sprint for issue %d set to %d\n", i.ID, sprintID)
371 fmt.Printf("[ok] sprint for issue %d was already set to %d, not updating\n", i.ID, i.FixedVersion.ID)
376 func checkError(err error) {
378 fmt.Printf("%s\n", err.Error())
383 func checkError2(msg string, err error) {
385 fmt.Printf("%s: %s\n", msg, err.Error())
390 var findAndAssociateIssuesCmd = &cobra.Command{
391 Use: "find-and-associate",
392 Short: "Find all issue numbers to associate with a release, and associate them",
393 Long: "Find all issue numbers to associate with a release, and associate them.\n" +
394 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
395 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
396 Run: func(cmd *cobra.Command, args []string) {
397 previousReleaseTag, err := cmd.Flags().GetString("previous-release-tag")
399 log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
403 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
405 log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
408 releaseID, err := cmd.Flags().GetInt("release")
410 log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
414 autoSet, err := cmd.Flags().GetBool("auto-set")
416 log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
419 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
421 log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
424 arvRepo, err := cmd.Flags().GetString("source-repo")
426 log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
430 if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
431 log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
434 if len(newReleaseCommitHash) != 7 && len(newReleaseCommitHash) != 40 {
435 log.Fatal(fmt.Errorf("The new-release-commit argument is of an unexpected format. Expecting a git commit hash (7 or 40 digits long)"))
439 // Clone the repo in memory
441 // our own arvados repo won't clone,
442 //arvRepo := "https://git.arvados.org/arvados.git"
443 //arvRepo := "https://github.com/arvados/arvados.git"
445 fmt.Println("Cloning " + arvRepo)
446 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
450 fmt.Println("... done")
452 start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
453 checkError2("repo.ResolveRevision", err)
454 fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
455 fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
458 // Build the exclusion list
459 seen := make(map[plumbing.Hash]bool)
460 excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
461 checkError2("repo.Log", err)
462 excludeIter.ForEach(func(c *object.Commit) error {
467 // isValid returns merge commits that are not in the exclusion list
468 var isValid object.CommitFilter = func(commit *object.Commit) bool {
469 _, ok := seen[commit.Hash]
471 // use len(commit.ParentHashes) to only get merge commits
472 return !ok && len(commit.ParentHashes) >= 2
475 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
476 checkError2("repo.CommitObject", err)
478 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
480 issues := make(map[int]string)
481 re := regexp.MustCompile(`Merge branch `)
482 reNotMain := regexp.MustCompile(`Merge branch .(main|master)`)
483 reIssueRef := regexp.MustCompile(`(Closes|closes|Refs|refs|Fixes|fixes) #(\d+)`)
484 err = iter.ForEach(func(c *object.Commit) error {
485 // We have a git commit hook that requires an issue reference on merge commits
486 if re.MatchString(c.Message) && !reNotMain.MatchString(c.Message) {
487 m := reIssueRef.FindStringSubmatch(c.Message)
489 i, err := strconv.Atoi(m[2])
493 issues[i] = fmt.Sprintf("%s: %s", c.Hash, c.Message)
497 if c.Hash == *start {
498 return storer.ErrStop
504 // Sort the issue map keys
505 keys := make([]int, 0, len(issues))
506 for k := range issues {
507 keys = append(keys, k)
511 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
513 for c, k := range keys {
514 fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
515 // Look up the issue, see if it is already associated with the desired release
517 i, err := r.GetIssue(k)
520 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
521 fmt.Println("============================================")
524 fmt.Println(i.Subject)
525 fmt.Println(issues[k])
527 if i.Release != nil && i.Release["release"].ID != 0 {
528 if i.Release["release"].ID == releaseID {
529 fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
530 } else if !skipReleaseChange {
531 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
533 prompt := &survey.Confirm{
534 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
536 err = survey.AskOne(prompt, &confirm)
541 err = r.SetRelease(*i, releaseID)
545 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
549 fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
552 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
555 prompt := &survey.Confirm{
556 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
558 err = survey.AskOne(prompt, &confirm)
563 if confirm || autoSet {
564 err = r.SetRelease(*i, releaseID)
568 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
572 fmt.Println("============================================")
577 var createReleaseIssueCmd = &cobra.Command{
578 Use: "create-release-issue",
579 Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
580 Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
581 "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
582 "\nFinally, a new Redmine release will also be created for the next release.\n" +
583 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
584 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
585 Run: func(cmd *cobra.Command, args []string) {
586 newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
588 log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
592 versionID, err := cmd.Flags().GetInt("sprint")
594 log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
597 projectName, err := cmd.Flags().GetString("project")
599 log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
603 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
605 // Does this project exist?
606 project, err := r.GetProjectByName(projectName)
608 log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
611 // Is the sprint (aka "version" in redmine) in the correct state?
612 v, err := r.Version(versionID)
614 log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
616 if v.Status != "open" {
617 log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
620 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
624 if i.Status.Name != "New" {
625 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))
628 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
630 // Get the list of subtasks from the "TASKS" file
631 tasks, err := os.Open("TASKS")
633 log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
637 scanner := bufio.NewScanner(tasks)
640 task := scanner.Text()
641 taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
642 fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
645 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
649 // Create the next release in Redmine
650 version, err := semver.NewVersion(newReleaseVersion)
652 log.Fatalf("Error parsing version: %s", err)
654 nextVersion := version.IncPatch()
656 var release *redmine.Release
658 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
660 log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
663 // No release found, create it
664 release = &redmine.Release{}
665 release.Name = "Arvados " + nextVersion.String()
666 release.Sharing = "hierarchy"
667 release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
668 release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02") // also arbitrary, 5 weeks from today
669 release.ProjectID = project.ID
670 release.Status = "open"
672 tmp, err := r.GetProject(release.ProjectID)
674 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
676 release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
678 release, err = r.CreateRelease(*release)
680 log.Fatalf("Unable to create release: %s", err)
683 fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
687 var releasesCmd = &cobra.Command{
689 Short: "Manage Redmine releases",
690 Long: "Manage Redmine releases.\n" +
691 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
692 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
695 var getReleaseCmd = &cobra.Command{
697 Short: "get a release",
698 Long: "Get a release.\n" +
699 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
700 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
701 Run: func(cmd *cobra.Command, args []string) {
702 releaseID, err := cmd.Flags().GetInt("release")
704 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
708 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
710 release, err := r.GetRelease(releaseID)
712 log.Fatalf("Error finding release with id %d: %s", releaseID, err)
714 releaseStr, err := json.MarshalIndent(release, "", " ")
716 log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
718 fmt.Println(string(releaseStr))