21931: Support uploading the arvados-google-api-client gem
[arvados-dev.git] / cmd / art / redmine.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: Apache-2.0
4
5 package main
6
7 import (
8         "bufio"
9         "encoding/json"
10         "fmt"
11         "log"
12         "os"
13         "regexp"
14         "sort"
15         "strconv"
16         "sync"
17         "time"
18
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"
28 )
29
30 func init() {
31         rootCmd.AddCommand(redmineCmd)
32         redmineCmd.AddCommand(issuesCmd)
33         redmineCmd.AddCommand(releasesCmd)
34
35         associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
36         err := associateIssueCmd.MarkFlagRequired("release")
37         if err != nil {
38                 log.Fatalf(err.Error())
39         }
40         associateIssueCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
41         err = associateIssueCmd.MarkFlagRequired("issue")
42         if err != nil {
43                 log.Fatalf(err.Error())
44         }
45         issuesCmd.AddCommand(associateIssueCmd)
46
47
48         setIssueSprintCmd.Flags().IntP("sprint", "r", 0, "Redmine sprint ID")
49         err = setIssueSprintCmd.MarkFlagRequired("sprint")
50         if err != nil {
51                 log.Fatalf(err.Error())
52         }
53         setIssueSprintCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
54         err = setIssueSprintCmd.MarkFlagRequired("issue")
55         if err != nil {
56                 log.Fatalf(err.Error())
57         }
58         issuesCmd.AddCommand(setIssueSprintCmd)
59
60         associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
61         err = associateOrphans.MarkFlagRequired("release")
62         if err != nil {
63                 log.Fatalf(err.Error())
64         }
65         associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
66         err = associateOrphans.MarkFlagRequired("project")
67         if err != nil {
68                 log.Fatalf(err.Error())
69         }
70         associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
71         issuesCmd.AddCommand(associateOrphans)
72
73         findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
74         err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
75         if err != nil {
76                 log.Fatalf(err.Error())
77         }
78         findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
79         err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
80         if err != nil {
81                 log.Fatalf(err.Error())
82         }
83         findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
84         err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
85         if err != nil {
86                 log.Fatalf(err.Error())
87         }
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")
91         if err != nil {
92                 log.Fatalf(err.Error())
93         }
94
95         issuesCmd.AddCommand(findAndAssociateIssuesCmd)
96
97         createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
98         err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
99         if err != nil {
100                 log.Fatalf(err.Error())
101         }
102         createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
103         err = createReleaseIssueCmd.MarkFlagRequired("sprint")
104         if err != nil {
105                 log.Fatalf(err.Error())
106         }
107         createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
108         err = createReleaseIssueCmd.MarkFlagRequired("project")
109         if err != nil {
110                 log.Fatalf(err.Error())
111         }
112         issuesCmd.AddCommand(createReleaseIssueCmd)
113
114         getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
115         err = getReleaseCmd.MarkFlagRequired("release")
116         if err != nil {
117                 log.Fatalf(err.Error())
118         }
119         releasesCmd.AddCommand(getReleaseCmd)
120 }
121
122 var redmineCmd = &cobra.Command{
123         Use:   "redmine",
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 == "" {
130                         cmd.Help()
131                         fmt.Println()
132                         fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
133                         os.Exit(1)
134                 }
135                 if conf.Apikey == "" {
136                         cmd.Help()
137                         fmt.Println()
138                         fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
139                         os.Exit(1)
140                 }
141                 return nil
142         },
143 }
144
145 var issuesCmd = &cobra.Command{
146         Use:   "issues",
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.",
151 }
152
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")
161                 if err != nil {
162                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
163                         os.Exit(1)
164                 }
165                 pName, err := cmd.Flags().GetString("project")
166                 if err != nil {
167                         log.Fatalf("Error getting the requested project name: %s", err)
168                 }
169                 dryRun, err := cmd.Flags().GetBool("dry-run")
170                 if err != nil {
171                         log.Fatalf("Error getting the dry-run parameter")
172                 }
173
174                 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
175                 p, err := rm.GetProjectByName(pName)
176                 if err != nil {
177                         log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
178                 }
179                 r, err := rm.GetRelease(rID)
180                 if err != nil {
181                         log.Fatalf("Error retrieving release '%d': %s", rID, err)
182                 }
183                 flt := redmine.IssueFilter{
184                         StatusID:  "open",
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
190                         ReleaseID: "!*",
191                         VersionID: "!*",
192                         ParentID:  "!*",
193                 }
194                 issues, err := rm.FilteredIssues(&flt)
195                 if err != nil {
196                         fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
197                 }
198                 fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
199
200                 type job struct {
201                         issue  redmine.Issue
202                         rID    int
203                         dryRun bool
204                 }
205                 type result struct {
206                         msg     string
207                         success bool
208                 }
209                 var wg sync.WaitGroup
210                 jobs := make(chan job, len(issues))
211                 results := make(chan result, len(issues))
212
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)
216                                 success := true
217                                 if !j.dryRun {
218                                         err = rm.SetRelease(j.issue, j.rID)
219                                         if err != nil {
220                                                 success = false
221                                                 msg = fmt.Sprintf("%s [error] (%s)\n", msg, err)
222                                         } else {
223                                                 msg = fmt.Sprintf("%s [changed]\n", msg)
224                                         }
225                                 } else {
226                                         msg = fmt.Sprintf("%s [skipped]\n", msg)
227                                 }
228                                 results <- result{
229                                         msg:     msg,
230                                         success: success,
231                                 }
232                         }
233                 }
234
235                 wn := 8
236                 if len(issues) < wn {
237                         wn = len(issues)
238                 }
239                 for w := 1; w <= wn; w++ {
240                         wg.Add(1)
241                         w := w
242                         go func() {
243                                 defer wg.Done()
244                                 worker(w, jobs, results)
245                         }()
246                 }
247
248                 for _, issue := range issues {
249                         jobs <- job{
250                                 issue:  issue,
251                                 rID:    rID,
252                                 dryRun: dryRun,
253                         }
254                 }
255                 close(jobs)
256
257                 succeded := true
258                 errCount := 0
259                 var wg2 sync.WaitGroup
260                 wg2.Add(1)
261                 go func() {
262                         defer wg2.Done()
263                         for r := range results {
264                                 fmt.Printf(r.msg)
265                                 if !r.success {
266                                         succeded = false
267                                         errCount += 1
268                                 }
269                         }
270                 }()
271
272                 wg.Wait()
273                 close(results)
274                 wg2.Wait()
275                 if !succeded {
276                         log.Fatalf("Warning: %d error(s) found.", errCount)
277                 }
278         },
279 }
280
281 var associateIssueCmd = &cobra.Command{
282         Use:   "associate",
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")
289                 if err != nil {
290                         fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
291                         os.Exit(1)
292                 }
293
294                 releaseID, err := cmd.Flags().GetInt("release")
295                 if err != nil {
296                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
297                         os.Exit(1)
298                 }
299
300                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
301
302                 i, err := redmine.GetIssue(issueID)
303                 if err != nil {
304                         fmt.Printf("%s\n", err.Error())
305                         os.Exit(1)
306                 }
307
308                 var setIt bool
309                 if i.Release == nil || i.Release["release"].ID == 0 {
310                         setIt = true
311                 } else if i.Release["release"].ID != releaseID {
312                         setIt = true
313                 }
314                 if setIt {
315                         err = redmine.SetRelease(*i, releaseID)
316                         if err != nil {
317                                 fmt.Printf("%s\n", err.Error())
318                                 os.Exit(1)
319                         } else {
320                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
321                         }
322                 } else {
323                         fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
324                 }
325         },
326 }
327
328
329 var setIssueSprintCmd = &cobra.Command{
330         Use:   "set-sprint",
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")
337                 if err != nil {
338                         fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
339                         os.Exit(1)
340                 }
341
342                 sprintID, err := cmd.Flags().GetInt("sprint")
343                 if err != nil {
344                         fmt.Printf("Error converting Redmine sprint ID to integer: %s", err)
345                         os.Exit(1)
346                 }
347
348                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
349
350                 i, err := redmine.GetIssue(issueID)
351                 if err != nil {
352                         fmt.Printf("%s\n", err.Error())
353                         os.Exit(1)
354                 }
355
356                 var setIt bool
357                 if i.FixedVersion == nil {
358                         setIt = true
359                 } else if i.FixedVersion.ID != sprintID {
360                         setIt = true
361                 }
362                 if setIt {
363                         err = redmine.SetSprint(*i, sprintID)
364                         if err != nil {
365                                 fmt.Printf("%s\n", err.Error())
366                                 os.Exit(1)
367                         } else {
368                                 fmt.Printf("[changed] sprint for issue %d set to %d\n", i.ID, sprintID)
369                         }
370                 } else {
371                         fmt.Printf("[ok] sprint for issue %d was already set to %d, not updating\n", i.ID, i.FixedVersion.ID)
372                 }
373         },
374 }
375
376 func checkError(err error) {
377         if err != nil {
378                 fmt.Printf("%s\n", err.Error())
379                 os.Exit(1)
380         }
381 }
382
383 func checkError2(msg string, err error) {
384         if err != nil {
385                 fmt.Printf("%s: %s\n", msg, err.Error())
386                 os.Exit(1)
387         }
388 }
389
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")
398                 if err != nil {
399                         log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
400                         return
401                 }
402
403                 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
404                 if err != nil {
405                         log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
406                         return
407                 }
408                 releaseID, err := cmd.Flags().GetInt("release")
409                 if err != nil {
410                         log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
411                         return
412                 }
413
414                 autoSet, err := cmd.Flags().GetBool("auto-set")
415                 if err != nil {
416                         log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
417                         return
418                 }
419                 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
420                 if err != nil {
421                         log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
422                         return
423                 }
424                 arvRepo, err := cmd.Flags().GetString("source-repo")
425                 if err != nil {
426                         log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
427                         return
428                 }
429
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)"))
432                         return
433                 }
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)"))
436                         return
437                 }
438
439                 // Clone the repo in memory
440
441                 // our own arvados repo won't clone,
442                 //arvRepo := "https://git.arvados.org/arvados.git"
443                 //arvRepo := "https://github.com/arvados/arvados.git"
444
445                 fmt.Println("Cloning " + arvRepo)
446                 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
447                         URL: arvRepo,
448                 })
449                 checkError(err)
450                 fmt.Println("... done")
451                 fmt.Println()
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)
456                 fmt.Println()
457
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 {
463                         seen[c.Hash] = true
464                         return nil
465                 })
466
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]
470
471                         // use len(commit.ParentHashes) to only get merge commits
472                         return !ok && len(commit.ParentHashes) >= 2
473                 }
474
475                 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
476                 checkError2("repo.CommitObject", err)
477
478                 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
479
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)
488                                 if len(m) == 3 {
489                                         i, err := strconv.Atoi(m[2])
490                                         if err != nil {
491                                                 checkError(err)
492                                         }
493                                         issues[i] = fmt.Sprintf("%s: %s", c.Hash, c.Message)
494                                 }
495                         }
496
497                         if c.Hash == *start {
498                                 return storer.ErrStop
499                         }
500                         return nil
501                 })
502                 checkError(err)
503
504                 // Sort the issue map keys
505                 keys := make([]int, 0, len(issues))
506                 for k := range issues {
507                         keys = append(keys, k)
508                 }
509                 sort.Ints(keys)
510
511                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
512
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
516
517                         i, err := r.GetIssue(k)
518                         if err != nil {
519                                 fmt.Println()
520                                 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
521                                 fmt.Println("============================================")
522                                 continue
523                         }
524                         fmt.Println(i.Subject)
525                         fmt.Println(issues[k])
526
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)
532                                         confirm := false
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),
535                                         }
536                                         err = survey.AskOne(prompt, &confirm)
537                                         if err != nil {
538                                                 log.Fatal(err)
539                                         }
540                                         if confirm {
541                                                 err = r.SetRelease(*i, releaseID)
542                                                 if err != nil {
543                                                         log.Fatal(err)
544                                                 } else {
545                                                         fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
546                                                 }
547                                         }
548                                 } else {
549                                         fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
550                                 }
551                         } else {
552                                 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
553                                 confirm := false
554                                 if !autoSet {
555                                         prompt := &survey.Confirm{
556                                                 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
557                                         }
558                                         err = survey.AskOne(prompt, &confirm)
559                                         if err != nil {
560                                                 return
561                                         }
562                                 }
563                                 if confirm || autoSet {
564                                         err = r.SetRelease(*i, releaseID)
565                                         if err != nil {
566                                                 log.Fatal(err)
567                                         } else {
568                                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
569                                         }
570                                 }
571                         }
572                         fmt.Println("============================================")
573                 }
574         },
575 }
576
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")
587                 if err != nil {
588                         log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
589                         return
590                 }
591
592                 versionID, err := cmd.Flags().GetInt("sprint")
593                 if err != nil {
594                         log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
595                         return
596                 }
597                 projectName, err := cmd.Flags().GetString("project")
598                 if err != nil {
599                         log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
600                         return
601                 }
602
603                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
604
605                 // Does this project exist?
606                 project, err := r.GetProjectByName(projectName)
607                 if err != nil {
608                         log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
609                 }
610
611                 // Is the sprint (aka "version" in redmine) in the correct state?
612                 v, err := r.Version(versionID)
613                 if err != nil {
614                         log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
615                 }
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))
618                 }
619
620                 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
621                 if err != nil {
622                         log.Fatal(err)
623                 }
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))
626                 }
627
628                 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
629
630                 // Get the list of subtasks from the "TASKS" file
631                 tasks, err := os.Open("TASKS")
632                 if err != nil {
633                         log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
634                 }
635                 defer tasks.Close()
636
637                 scanner := bufio.NewScanner(tasks)
638                 count := 1
639                 for scanner.Scan() {
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)
643                         count++
644                         if err != nil {
645                                 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
646                         }
647                 }
648
649                 // Create the next release in Redmine
650                 version, err := semver.NewVersion(newReleaseVersion)
651                 if err != nil {
652                         log.Fatalf("Error parsing version: %s", err)
653                 }
654                 nextVersion := version.IncPatch()
655
656                 var release *redmine.Release
657
658                 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
659                 if err != nil {
660                         log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
661                 }
662                 if release == nil {
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"
671                         // Populate Project
672                         tmp, err := r.GetProject(release.ProjectID)
673                         if err != nil {
674                                 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
675                         }
676                         release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
677
678                         release, err = r.CreateRelease(*release)
679                         if err != nil {
680                                 log.Fatalf("Unable to create release: %s", err)
681                         }
682                 }
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)
684         },
685 }
686
687 var releasesCmd = &cobra.Command{
688         Use:   "releases",
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.",
693 }
694
695 var getReleaseCmd = &cobra.Command{
696         Use:   "get",
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")
703                 if err != nil {
704                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
705                         os.Exit(1)
706                 }
707
708                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
709
710                 release, err := r.GetRelease(releaseID)
711                 if err != nil {
712                         log.Fatalf("Error finding release with id %d: %s", releaseID, err)
713                 }
714                 releaseStr, err := json.MarshalIndent(release, "", "  ")
715                 if err != nil {
716                         log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
717                 }
718                 fmt.Println(string(releaseStr))
719
720         },
721 }