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)
47 associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
48 err = associateOrphans.MarkFlagRequired("release")
50 log.Fatalf(err.Error())
52 associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
53 err = associateOrphans.MarkFlagRequired("project")
55 log.Fatalf(err.Error())
57 associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
58 issuesCmd.AddCommand(associateOrphans)
60 findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
61 err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
63 log.Fatalf(err.Error())
65 findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
66 err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
68 log.Fatalf(err.Error())
70 findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
71 err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
73 log.Fatalf(err.Error())
75 findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
76 findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
77 findAndAssociateIssuesCmd.Flags().StringP("source-repo", "", "https://github.com/arvados/arvados.git", "Source repository to clone from")
79 log.Fatalf(err.Error())
82 issuesCmd.AddCommand(findAndAssociateIssuesCmd)
84 createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
85 err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
87 log.Fatalf(err.Error())
89 createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
90 err = createReleaseIssueCmd.MarkFlagRequired("sprint")
92 log.Fatalf(err.Error())
94 createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
95 err = createReleaseIssueCmd.MarkFlagRequired("project")
97 log.Fatalf(err.Error())
99 issuesCmd.AddCommand(createReleaseIssueCmd)
101 getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
102 err = getReleaseCmd.MarkFlagRequired("release")
104 log.Fatalf(err.Error())
106 releasesCmd.AddCommand(getReleaseCmd)
109 var redmineCmd = &cobra.Command{
111 Short: "Manage Redmine",
112 Long: "Manage Redmine.\n" +
113 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
114 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
115 PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
116 if conf.Endpoint == "" {
119 fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
122 if conf.Apikey == "" {
125 fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
132 var issuesCmd = &cobra.Command{
134 Short: "Manage Redmine issues",
135 Long: "Manage Redmine issues.\n" +
136 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
137 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
140 var associateOrphans = &cobra.Command{
141 Use: "associate-orphans", // FIXME
142 Short: "Find open issues without a release and version, assign them to the given release",
143 Long: "Find open issues without a release and version, assign them to the given release.\n" +
144 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
145 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
146 Run: func(cmd *cobra.Command, args []string) {
147 rID, err := cmd.Flags().GetInt("release")
149 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
152 pName, err := cmd.Flags().GetString("project")
154 log.Fatalf("Error getting the requested project name: %s", err)
156 dryRun, err := cmd.Flags().GetBool("dry-run")
158 log.Fatalf("Error getting the dry-run parameter")
161 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
162 p, err := rm.GetProjectByName(pName)
164 log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
166 r, err := rm.GetRelease(rID)
168 log.Fatalf("Error retrieving release '%d': %s", rID, err)
170 flt := redmine.IssueFilter{
172 ProjectID: fmt.Sprintf("%d", p.ID),
173 // No values assigned on the following fields. It seems that using
174 // an empty string is interpreted as 'any value'. The documentation
175 // isn't clear, but after some trial & error, '!*' seems to do the trick.
176 // https://www.redmine.org/projects/redmine/wiki/Rest_Issues
181 issues, err := rm.FilteredIssues(&flt)
183 fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
185 fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
196 var wg sync.WaitGroup
197 jobs := make(chan job, len(issues))
198 results := make(chan result, len(issues))
200 worker := func(id int, jobs <-chan job, results chan<- result) {
201 for j := range jobs {
202 msg := fmt.Sprintf("#%d - %s ", j.issue.ID, j.issue.Subject)
205 err = rm.SetRelease(j.issue, j.rID)
208 msg = fmt.Sprintf("%s [error] (%s)\n", msg, err)
210 msg = fmt.Sprintf("%s [changed]\n", msg)
213 msg = fmt.Sprintf("%s [skipped]\n", msg)
223 if len(issues) < wn {
226 for w := 1; w <= wn; w++ {
231 worker(w, jobs, results)
235 for _, issue := range issues {
246 var wg2 sync.WaitGroup
250 for r := range results {
263 log.Fatalf("Warning: %d error(s) found.", errCount)
268 var associateIssueCmd = &cobra.Command{
270 Short: "Associate an issue with a release",
271 Long: "Associate an issue with a release.\n" +
272 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
273 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
274 Run: func(cmd *cobra.Command, args []string) {
275 issueID, err := cmd.Flags().GetInt("issue")
277 fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
281 releaseID, err := cmd.Flags().GetInt("release")
283 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
287 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
289 i, err := redmine.GetIssue(issueID)
291 fmt.Printf("%s\n", err.Error())
296 if i.Release == nil || i.Release["release"].ID == 0 {
298 } else if i.Release["release"].ID != releaseID {
302 err = redmine.SetRelease(*i, releaseID)
304 fmt.Printf("%s\n", err.Error())
307 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
310 fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
315 func checkError(err error) {
317 fmt.Printf("%s\n", err.Error())
322 func checkError2(msg string, err error) {
324 fmt.Printf("%s: %s\n", msg, err.Error())
329 var findAndAssociateIssuesCmd = &cobra.Command{
330 Use: "find-and-associate",
331 Short: "Find all issue numbers to associate with a release, and associate them",
332 Long: "Find all issue numbers to associate with a release, and associate them.\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 previousReleaseTag, err := cmd.Flags().GetString("previous-release-tag")
338 log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
342 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
344 log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
347 releaseID, err := cmd.Flags().GetInt("release")
349 log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
353 autoSet, err := cmd.Flags().GetBool("auto-set")
355 log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
358 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
360 log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
363 arvRepo, err := cmd.Flags().GetString("source-repo")
365 log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
369 if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
370 log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
373 if len(newReleaseCommitHash) != 7 && len(newReleaseCommitHash) != 40 {
374 log.Fatal(fmt.Errorf("The new-release-commit argument is of an unexpected format. Expecting a git commit hash (7 or 40 digits long)"))
378 // Clone the repo in memory
380 // our own arvados repo won't clone,
381 //arvRepo := "https://git.arvados.org/arvados.git"
382 //arvRepo := "https://github.com/arvados/arvados.git"
384 fmt.Println("Cloning " + arvRepo)
385 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
389 fmt.Println("... done")
391 start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
392 checkError2("repo.ResolveRevision", err)
393 fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
394 fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
397 // Build the exclusion list
398 seen := make(map[plumbing.Hash]bool)
399 excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
400 checkError2("repo.Log", err)
401 excludeIter.ForEach(func(c *object.Commit) error {
406 // isValid returns merge commits that are not in the exclusion list
407 var isValid object.CommitFilter = func(commit *object.Commit) bool {
408 _, ok := seen[commit.Hash]
410 // use len(commit.ParentHashes) to only get merge commits
411 return !ok && len(commit.ParentHashes) >= 2
414 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
415 checkError2("repo.CommitObject", err)
417 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
419 issues := make(map[int]bool)
420 re := regexp.MustCompile(`Merge branch `)
421 reNotMain := regexp.MustCompile(`Merge branch .(main|master)`)
422 reIssueRef := regexp.MustCompile(`(Closes|closes|Refs|refs|Fixes|fixes) #(\d+)`)
423 err = iter.ForEach(func(c *object.Commit) error {
424 // We have a git commit hook that requires an issue reference on merge commits
425 if re.MatchString(c.Message) && !reNotMain.MatchString(c.Message) {
426 m := reIssueRef.FindStringSubmatch(c.Message)
428 i, err := strconv.Atoi(m[2])
436 if c.Hash == *start {
437 return storer.ErrStop
443 // Sort the issue map keys
444 keys := make([]int, 0, len(issues))
445 for k := range issues {
446 keys = append(keys, k)
450 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
452 for c, k := range keys {
453 fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
454 // Look up the issue, see if it is already associated with the desired release
456 i, err := r.GetIssue(k)
459 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
460 fmt.Println("============================================")
463 fmt.Println(i.Subject)
465 if i.Release != nil && i.Release["release"].ID != 0 {
466 if i.Release["release"].ID == releaseID {
467 fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
468 } else if !skipReleaseChange {
469 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
471 prompt := &survey.Confirm{
472 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
474 err = survey.AskOne(prompt, &confirm)
479 err = r.SetRelease(*i, releaseID)
483 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
487 fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
490 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
493 prompt := &survey.Confirm{
494 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
496 err = survey.AskOne(prompt, &confirm)
501 if confirm || autoSet {
502 err = r.SetRelease(*i, releaseID)
506 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
510 fmt.Println("============================================")
515 var createReleaseIssueCmd = &cobra.Command{
516 Use: "create-release-issue",
517 Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
518 Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
519 "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
520 "\nFinally, a new Redmine release will also be created for the next release.\n" +
521 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
522 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
523 Run: func(cmd *cobra.Command, args []string) {
524 newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
526 log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
530 versionID, err := cmd.Flags().GetInt("sprint")
532 log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
535 projectName, err := cmd.Flags().GetString("project")
537 log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
541 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
543 // Does this project exist?
544 project, err := r.GetProjectByName(projectName)
546 log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
549 // Is the sprint (aka "version" in redmine) in the correct state?
550 v, err := r.Version(versionID)
552 log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
554 if v.Status != "open" {
555 log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
558 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
562 if i.Status.Name != "New" {
563 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))
566 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
568 // Get the list of subtasks from the "TASKS" file
569 tasks, err := os.Open("TASKS")
571 log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
575 scanner := bufio.NewScanner(tasks)
578 task := scanner.Text()
579 taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
580 fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
583 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
587 // Create the next release in Redmine
588 version, err := semver.NewVersion(newReleaseVersion)
590 log.Fatalf("Error parsing version: %s", err)
592 nextVersion := version.IncPatch()
594 var release *redmine.Release
596 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
598 log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
601 // No release found, create it
602 release = &redmine.Release{}
603 release.Name = "Arvados " + nextVersion.String()
604 release.Sharing = "hierarchy"
605 release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
606 release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02") // also arbitrary, 5 weeks from today
607 release.ProjectID = project.ID
608 release.Status = "open"
610 tmp, err := r.GetProject(release.ProjectID)
612 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
614 release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
616 release, err = r.CreateRelease(*release)
618 log.Fatalf("Unable to create release: %s", err)
621 fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
625 var releasesCmd = &cobra.Command{
627 Short: "Manage Redmine releases",
628 Long: "Manage Redmine releases.\n" +
629 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
630 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
633 var getReleaseCmd = &cobra.Command{
635 Short: "get a release",
636 Long: "Get a release.\n" +
637 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
638 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
639 Run: func(cmd *cobra.Command, args []string) {
640 releaseID, err := cmd.Flags().GetInt("release")
642 fmt.Printf("Error converting Redmine release ID to integer: %s", err)
646 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
648 release, err := r.GetRelease(releaseID)
650 log.Fatalf("Error finding release with id %d: %s", releaseID, err)
652 releaseStr, err := json.MarshalIndent(release, "", " ")
654 log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
656 fmt.Println(string(releaseStr))