Adds worker pool to speed up processing lots of issues. Refs #19920
[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         associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
48         err = associateOrphans.MarkFlagRequired("release")
49         if err != nil {
50                 log.Fatalf(err.Error())
51         }
52         associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
53         err = associateOrphans.MarkFlagRequired("project")
54         if err != nil {
55                 log.Fatalf(err.Error())
56         }
57         associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
58         issuesCmd.AddCommand(associateOrphans)
59
60         findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
61         err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
62         if err != nil {
63                 log.Fatalf(err.Error())
64         }
65         findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
66         err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
67         if err != nil {
68                 log.Fatalf(err.Error())
69         }
70         findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
71         err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
72         if err != nil {
73                 log.Fatalf(err.Error())
74         }
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")
78         if err != nil {
79                 log.Fatalf(err.Error())
80         }
81
82         issuesCmd.AddCommand(findAndAssociateIssuesCmd)
83
84         createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
85         err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
86         if err != nil {
87                 log.Fatalf(err.Error())
88         }
89         createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
90         err = createReleaseIssueCmd.MarkFlagRequired("sprint")
91         if err != nil {
92                 log.Fatalf(err.Error())
93         }
94         createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
95         err = createReleaseIssueCmd.MarkFlagRequired("project")
96         if err != nil {
97                 log.Fatalf(err.Error())
98         }
99         issuesCmd.AddCommand(createReleaseIssueCmd)
100
101         getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
102         err = getReleaseCmd.MarkFlagRequired("release")
103         if err != nil {
104                 log.Fatalf(err.Error())
105         }
106         releasesCmd.AddCommand(getReleaseCmd)
107 }
108
109 var redmineCmd = &cobra.Command{
110         Use:   "redmine",
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 == "" {
117                         cmd.Help()
118                         fmt.Println()
119                         fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
120                         os.Exit(1)
121                 }
122                 if conf.Apikey == "" {
123                         cmd.Help()
124                         fmt.Println()
125                         fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
126                         os.Exit(1)
127                 }
128                 return nil
129         },
130 }
131
132 var issuesCmd = &cobra.Command{
133         Use:   "issues",
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.",
138 }
139
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")
148                 if err != nil {
149                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
150                         os.Exit(1)
151                 }
152                 pName, err := cmd.Flags().GetString("project")
153                 if err != nil {
154                         log.Fatalf("Error getting the requested project name: %s", err)
155                 }
156                 dryRun, err := cmd.Flags().GetBool("dry-run")
157                 if err != nil {
158                         log.Fatalf("Error getting the dry-run parameter")
159                 }
160
161                 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
162                 p, err := rm.GetProjectByName(pName)
163                 if err != nil {
164                         log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
165                 }
166                 r, err := rm.GetRelease(rID)
167                 if err != nil {
168                         log.Fatalf("Error retrieving release '%d': %s", rID, err)
169                 }
170                 flt := redmine.IssueFilter{
171                         StatusID:  "open",
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
177                         ReleaseID: "!*",
178                         VersionID: "!*",
179                         ParentID:  "!*",
180                 }
181                 issues, err := rm.FilteredIssues(&flt)
182                 if err != nil {
183                         fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
184                 }
185                 fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
186
187                 type job struct {
188                         issue  redmine.Issue
189                         rID    int
190                         dryRun bool
191                 }
192                 type result struct {
193                         msg     string
194                         success bool
195                 }
196                 var wg sync.WaitGroup
197                 jobs := make(chan job, len(issues))
198                 results := make(chan result, len(issues))
199
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)
203                                 success := true
204                                 if !j.dryRun {
205                                         err = rm.SetRelease(j.issue, j.rID)
206                                         if err != nil {
207                                                 success = false
208                                                 msg = fmt.Sprintf("%s [error] (%s)\n", msg, err)
209                                         } else {
210                                                 msg = fmt.Sprintf("%s [changed]\n", msg)
211                                         }
212                                 } else {
213                                         msg = fmt.Sprintf("%s [skipped]\n", msg)
214                                 }
215                                 results <- result{
216                                         msg:     msg,
217                                         success: success,
218                                 }
219                         }
220                 }
221
222                 wn := 8
223                 if len(issues) < wn {
224                         wn = len(issues)
225                 }
226                 for w := 1; w <= wn; w++ {
227                         wg.Add(1)
228                         w := w
229                         go func() {
230                                 defer wg.Done()
231                                 worker(w, jobs, results)
232                         }()
233                 }
234
235                 for _, issue := range issues {
236                         jobs <- job{
237                                 issue:  issue,
238                                 rID:    rID,
239                                 dryRun: dryRun,
240                         }
241                 }
242                 close(jobs)
243
244                 succeded := true
245                 errCount := 0
246                 var wg2 sync.WaitGroup
247                 wg2.Add(1)
248                 go func() {
249                         defer wg2.Done()
250                         for r := range results {
251                                 fmt.Printf(r.msg)
252                                 if !r.success {
253                                         succeded = false
254                                         errCount += 1
255                                 }
256                         }
257                 }()
258
259                 wg.Wait()
260                 close(results)
261                 wg2.Wait()
262                 if !succeded {
263                         log.Fatalf("Warning: %d error(s) found.", errCount)
264                 }
265         },
266 }
267
268 var associateIssueCmd = &cobra.Command{
269         Use:   "associate",
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")
276                 if err != nil {
277                         fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
278                         os.Exit(1)
279                 }
280
281                 releaseID, err := cmd.Flags().GetInt("release")
282                 if err != nil {
283                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
284                         os.Exit(1)
285                 }
286
287                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
288
289                 i, err := redmine.GetIssue(issueID)
290                 if err != nil {
291                         fmt.Printf("%s\n", err.Error())
292                         os.Exit(1)
293                 }
294
295                 var setIt bool
296                 if i.Release == nil || i.Release["release"].ID == 0 {
297                         setIt = true
298                 } else if i.Release["release"].ID != releaseID {
299                         setIt = true
300                 }
301                 if setIt {
302                         err = redmine.SetRelease(*i, releaseID)
303                         if err != nil {
304                                 fmt.Printf("%s\n", err.Error())
305                                 os.Exit(1)
306                         } else {
307                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
308                         }
309                 } else {
310                         fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
311                 }
312         },
313 }
314
315 func checkError(err error) {
316         if err != nil {
317                 fmt.Printf("%s\n", err.Error())
318                 os.Exit(1)
319         }
320 }
321
322 func checkError2(msg string, err error) {
323         if err != nil {
324                 fmt.Printf("%s: %s\n", msg, err.Error())
325                 os.Exit(1)
326         }
327 }
328
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")
337                 if err != nil {
338                         log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
339                         return
340                 }
341
342                 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
343                 if err != nil {
344                         log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
345                         return
346                 }
347                 releaseID, err := cmd.Flags().GetInt("release")
348                 if err != nil {
349                         log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
350                         return
351                 }
352
353                 autoSet, err := cmd.Flags().GetBool("auto-set")
354                 if err != nil {
355                         log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
356                         return
357                 }
358                 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
359                 if err != nil {
360                         log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
361                         return
362                 }
363                 arvRepo, err := cmd.Flags().GetString("source-repo")
364                 if err != nil {
365                         log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
366                         return
367                 }
368
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)"))
371                         return
372                 }
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)"))
375                         return
376                 }
377
378                 // Clone the repo in memory
379
380                 // our own arvados repo won't clone,
381                 //arvRepo := "https://git.arvados.org/arvados.git"
382                 //arvRepo := "https://github.com/arvados/arvados.git"
383
384                 fmt.Println("Cloning " + arvRepo)
385                 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
386                         URL: arvRepo,
387                 })
388                 checkError(err)
389                 fmt.Println("... done")
390                 fmt.Println()
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)
395                 fmt.Println()
396
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 {
402                         seen[c.Hash] = true
403                         return nil
404                 })
405
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]
409
410                         // use len(commit.ParentHashes) to only get merge commits
411                         return !ok && len(commit.ParentHashes) >= 2
412                 }
413
414                 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
415                 checkError2("repo.CommitObject", err)
416
417                 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
418
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)
427                                 if len(m) == 3 {
428                                         i, err := strconv.Atoi(m[2])
429                                         if err != nil {
430                                                 checkError(err)
431                                         }
432                                         issues[i] = true
433                                 }
434                         }
435
436                         if c.Hash == *start {
437                                 return storer.ErrStop
438                         }
439                         return nil
440                 })
441                 checkError(err)
442
443                 // Sort the issue map keys
444                 keys := make([]int, 0, len(issues))
445                 for k := range issues {
446                         keys = append(keys, k)
447                 }
448                 sort.Ints(keys)
449
450                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
451
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
455
456                         i, err := r.GetIssue(k)
457                         if err != nil {
458                                 fmt.Println()
459                                 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
460                                 fmt.Println("============================================")
461                                 continue
462                         }
463                         fmt.Println(i.Subject)
464
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)
470                                         confirm := false
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),
473                                         }
474                                         err = survey.AskOne(prompt, &confirm)
475                                         if err != nil {
476                                                 log.Fatal(err)
477                                         }
478                                         if confirm {
479                                                 err = r.SetRelease(*i, releaseID)
480                                                 if err != nil {
481                                                         log.Fatal(err)
482                                                 } else {
483                                                         fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
484                                                 }
485                                         }
486                                 } else {
487                                         fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
488                                 }
489                         } else {
490                                 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
491                                 confirm := false
492                                 if !autoSet {
493                                         prompt := &survey.Confirm{
494                                                 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
495                                         }
496                                         err = survey.AskOne(prompt, &confirm)
497                                         if err != nil {
498                                                 return
499                                         }
500                                 }
501                                 if confirm || autoSet {
502                                         err = r.SetRelease(*i, releaseID)
503                                         if err != nil {
504                                                 log.Fatal(err)
505                                         } else {
506                                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
507                                         }
508                                 }
509                         }
510                         fmt.Println("============================================")
511                 }
512         },
513 }
514
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")
525                 if err != nil {
526                         log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
527                         return
528                 }
529
530                 versionID, err := cmd.Flags().GetInt("sprint")
531                 if err != nil {
532                         log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
533                         return
534                 }
535                 projectName, err := cmd.Flags().GetString("project")
536                 if err != nil {
537                         log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
538                         return
539                 }
540
541                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
542
543                 // Does this project exist?
544                 project, err := r.GetProjectByName(projectName)
545                 if err != nil {
546                         log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
547                 }
548
549                 // Is the sprint (aka "version" in redmine) in the correct state?
550                 v, err := r.Version(versionID)
551                 if err != nil {
552                         log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
553                 }
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))
556                 }
557
558                 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
559                 if err != nil {
560                         log.Fatal(err)
561                 }
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))
564                 }
565
566                 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
567
568                 // Get the list of subtasks from the "TASKS" file
569                 tasks, err := os.Open("TASKS")
570                 if err != nil {
571                         log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
572                 }
573                 defer tasks.Close()
574
575                 scanner := bufio.NewScanner(tasks)
576                 count := 1
577                 for scanner.Scan() {
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)
581                         count++
582                         if err != nil {
583                                 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
584                         }
585                 }
586
587                 // Create the next release in Redmine
588                 version, err := semver.NewVersion(newReleaseVersion)
589                 if err != nil {
590                         log.Fatalf("Error parsing version: %s", err)
591                 }
592                 nextVersion := version.IncPatch()
593
594                 var release *redmine.Release
595
596                 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
597                 if err != nil {
598                         log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
599                 }
600                 if release == nil {
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"
609                         // Populate Project
610                         tmp, err := r.GetProject(release.ProjectID)
611                         if err != nil {
612                                 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
613                         }
614                         release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
615
616                         release, err = r.CreateRelease(*release)
617                         if err != nil {
618                                 log.Fatalf("Unable to create release: %s", err)
619                         }
620                 }
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)
622         },
623 }
624
625 var releasesCmd = &cobra.Command{
626         Use:   "releases",
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.",
631 }
632
633 var getReleaseCmd = &cobra.Command{
634         Use:   "get",
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")
641                 if err != nil {
642                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
643                         os.Exit(1)
644                 }
645
646                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
647
648                 release, err := r.GetRelease(releaseID)
649                 if err != nil {
650                         log.Fatalf("Error finding release with id %d: %s", releaseID, err)
651                 }
652                 releaseStr, err := json.MarshalIndent(release, "", "  ")
653                 if err != nil {
654                         log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
655                 }
656                 fmt.Println(string(releaseStr))
657
658         },
659 }