Merge branch '21521-aptly-flock'
[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]bool)
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] = true
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
526                         if i.Release != nil && i.Release["release"].ID != 0 {
527                                 if i.Release["release"].ID == releaseID {
528                                         fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
529                                 } else if !skipReleaseChange {
530                                         fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
531                                         confirm := false
532                                         prompt := &survey.Confirm{
533                                                 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
534                                         }
535                                         err = survey.AskOne(prompt, &confirm)
536                                         if err != nil {
537                                                 log.Fatal(err)
538                                         }
539                                         if confirm {
540                                                 err = r.SetRelease(*i, releaseID)
541                                                 if err != nil {
542                                                         log.Fatal(err)
543                                                 } else {
544                                                         fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
545                                                 }
546                                         }
547                                 } else {
548                                         fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
549                                 }
550                         } else {
551                                 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
552                                 confirm := false
553                                 if !autoSet {
554                                         prompt := &survey.Confirm{
555                                                 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
556                                         }
557                                         err = survey.AskOne(prompt, &confirm)
558                                         if err != nil {
559                                                 return
560                                         }
561                                 }
562                                 if confirm || autoSet {
563                                         err = r.SetRelease(*i, releaseID)
564                                         if err != nil {
565                                                 log.Fatal(err)
566                                         } else {
567                                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
568                                         }
569                                 }
570                         }
571                         fmt.Println("============================================")
572                 }
573         },
574 }
575
576 var createReleaseIssueCmd = &cobra.Command{
577         Use:   "create-release-issue",
578         Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
579         Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
580                 "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
581                 "\nFinally, a new Redmine release will also be created for the next release.\n" +
582                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
583                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
584         Run: func(cmd *cobra.Command, args []string) {
585                 newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
586                 if err != nil {
587                         log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
588                         return
589                 }
590
591                 versionID, err := cmd.Flags().GetInt("sprint")
592                 if err != nil {
593                         log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
594                         return
595                 }
596                 projectName, err := cmd.Flags().GetString("project")
597                 if err != nil {
598                         log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
599                         return
600                 }
601
602                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
603
604                 // Does this project exist?
605                 project, err := r.GetProjectByName(projectName)
606                 if err != nil {
607                         log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
608                 }
609
610                 // Is the sprint (aka "version" in redmine) in the correct state?
611                 v, err := r.Version(versionID)
612                 if err != nil {
613                         log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
614                 }
615                 if v.Status != "open" {
616                         log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
617                 }
618
619                 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
620                 if err != nil {
621                         log.Fatal(err)
622                 }
623                 if i.Status.Name != "New" {
624                         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))
625                 }
626
627                 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
628
629                 // Get the list of subtasks from the "TASKS" file
630                 tasks, err := os.Open("TASKS")
631                 if err != nil {
632                         log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
633                 }
634                 defer tasks.Close()
635
636                 scanner := bufio.NewScanner(tasks)
637                 count := 1
638                 for scanner.Scan() {
639                         task := scanner.Text()
640                         taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
641                         fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
642                         count++
643                         if err != nil {
644                                 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
645                         }
646                 }
647
648                 // Create the next release in Redmine
649                 version, err := semver.NewVersion(newReleaseVersion)
650                 if err != nil {
651                         log.Fatalf("Error parsing version: %s", err)
652                 }
653                 nextVersion := version.IncPatch()
654
655                 var release *redmine.Release
656
657                 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
658                 if err != nil {
659                         log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
660                 }
661                 if release == nil {
662                         // No release found, create it
663                         release = &redmine.Release{}
664                         release.Name = "Arvados " + nextVersion.String()
665                         release.Sharing = "hierarchy"
666                         release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
667                         release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02")   // also arbitrary, 5 weeks from today
668                         release.ProjectID = project.ID
669                         release.Status = "open"
670                         // Populate Project
671                         tmp, err := r.GetProject(release.ProjectID)
672                         if err != nil {
673                                 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
674                         }
675                         release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
676
677                         release, err = r.CreateRelease(*release)
678                         if err != nil {
679                                 log.Fatalf("Unable to create release: %s", err)
680                         }
681                 }
682                 fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
683         },
684 }
685
686 var releasesCmd = &cobra.Command{
687         Use:   "releases",
688         Short: "Manage Redmine releases",
689         Long: "Manage Redmine releases.\n" +
690                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
691                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
692 }
693
694 var getReleaseCmd = &cobra.Command{
695         Use:   "get",
696         Short: "get a release",
697         Long: "Get a release.\n" +
698                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
699                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
700         Run: func(cmd *cobra.Command, args []string) {
701                 releaseID, err := cmd.Flags().GetInt("release")
702                 if err != nil {
703                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
704                         os.Exit(1)
705                 }
706
707                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
708
709                 release, err := r.GetRelease(releaseID)
710                 if err != nil {
711                         log.Fatalf("Error finding release with id %d: %s", releaseID, err)
712                 }
713                 releaseStr, err := json.MarshalIndent(release, "", "  ")
714                 if err != nil {
715                         log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
716                 }
717                 fmt.Println(string(releaseStr))
718
719         },
720 }