Adds 'associate-orphans' subcommand. 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         "time"
17
18         "git.arvados.org/arvados-dev.git/lib/redmine"
19         survey "github.com/AlecAivazis/survey/v2"
20         "github.com/Masterminds/semver"
21         "github.com/go-git/go-git/v5"
22         "github.com/go-git/go-git/v5/plumbing"
23         "github.com/go-git/go-git/v5/plumbing/object"
24         "github.com/go-git/go-git/v5/plumbing/storer"
25         "github.com/go-git/go-git/v5/storage/memory"
26         "github.com/spf13/cobra"
27 )
28
29 func init() {
30         rootCmd.AddCommand(redmineCmd)
31         redmineCmd.AddCommand(issuesCmd)
32         redmineCmd.AddCommand(releasesCmd)
33
34         associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
35         err := associateIssueCmd.MarkFlagRequired("release")
36         if err != nil {
37                 log.Fatalf(err.Error())
38         }
39         associateIssueCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
40         err = associateIssueCmd.MarkFlagRequired("issue")
41         if err != nil {
42                 log.Fatalf(err.Error())
43         }
44         issuesCmd.AddCommand(associateIssueCmd)
45
46         associateOrphans.Flags().IntP("release", "r", 0, "Redmine release ID")
47         err = associateOrphans.MarkFlagRequired("release")
48         if err != nil {
49                 log.Fatalf(err.Error())
50         }
51         associateOrphans.Flags().StringP("project", "p", "", "Redmine project name")
52         err = associateOrphans.MarkFlagRequired("project")
53         if err != nil {
54                 log.Fatalf(err.Error())
55         }
56         associateOrphans.Flags().BoolP("dry-run", "", false, "Only report what will happen without making any change")
57         issuesCmd.AddCommand(associateOrphans)
58
59         findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
60         err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
61         if err != nil {
62                 log.Fatalf(err.Error())
63         }
64         findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
65         err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
66         if err != nil {
67                 log.Fatalf(err.Error())
68         }
69         findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
70         err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
71         if err != nil {
72                 log.Fatalf(err.Error())
73         }
74         findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
75         findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
76         findAndAssociateIssuesCmd.Flags().StringP("source-repo", "", "https://github.com/arvados/arvados.git", "Source repository to clone from")
77         if err != nil {
78                 log.Fatalf(err.Error())
79         }
80
81         issuesCmd.AddCommand(findAndAssociateIssuesCmd)
82
83         createReleaseIssueCmd.Flags().StringP("new-release-version", "n", "", "Semantic version number of the new release")
84         err = createReleaseIssueCmd.MarkFlagRequired("new-release-version")
85         if err != nil {
86                 log.Fatalf(err.Error())
87         }
88         createReleaseIssueCmd.Flags().IntP("sprint", "s", 0, "Redmine sprint (aka Version) ID")
89         err = createReleaseIssueCmd.MarkFlagRequired("sprint")
90         if err != nil {
91                 log.Fatalf(err.Error())
92         }
93         createReleaseIssueCmd.Flags().StringP("project", "p", "", "Redmine project name")
94         err = createReleaseIssueCmd.MarkFlagRequired("project")
95         if err != nil {
96                 log.Fatalf(err.Error())
97         }
98         issuesCmd.AddCommand(createReleaseIssueCmd)
99
100         getReleaseCmd.Flags().IntP("release", "r", 0, "ID of the redmine release")
101         err = getReleaseCmd.MarkFlagRequired("release")
102         if err != nil {
103                 log.Fatalf(err.Error())
104         }
105         releasesCmd.AddCommand(getReleaseCmd)
106 }
107
108 var redmineCmd = &cobra.Command{
109         Use:   "redmine",
110         Short: "Manage Redmine",
111         Long: "Manage Redmine.\n" +
112                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
113                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
114         PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
115                 if conf.Endpoint == "" {
116                         cmd.Help()
117                         fmt.Println()
118                         fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
119                         os.Exit(1)
120                 }
121                 if conf.Apikey == "" {
122                         cmd.Help()
123                         fmt.Println()
124                         fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
125                         os.Exit(1)
126                 }
127                 return nil
128         },
129 }
130
131 var issuesCmd = &cobra.Command{
132         Use:   "issues",
133         Short: "Manage Redmine issues",
134         Long: "Manage Redmine issues.\n" +
135                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
136                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
137 }
138
139 var associateOrphans = &cobra.Command{
140         Use:   "associate-orphans", // FIXME
141         Short: "Find open issues without a release and version, assign them to the given release",
142         Long: "Find open issues without a release and version, assign them to the given release.\n" +
143                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
144                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
145         Run: func(cmd *cobra.Command, args []string) {
146                 rID, err := cmd.Flags().GetInt("release")
147                 if err != nil {
148                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
149                         os.Exit(1)
150                 }
151                 pName, err := cmd.Flags().GetString("project")
152                 if err != nil {
153                         log.Fatalf("Error getting the requested project name: %s", err)
154                 }
155                 dryRun, err := cmd.Flags().GetBool("dry-run")
156                 if err != nil {
157                         log.Fatalf("Error getting the dry-run parameter")
158                 }
159
160                 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
161                 p, err := rm.GetProjectByName(pName)
162                 if err != nil {
163                         log.Fatalf("Error retrieving project ID for '%s': %s", pName, err)
164                 }
165                 r, err := rm.GetRelease(rID)
166                 if err != nil {
167                         log.Fatalf("Error retrieving release '%d': %s", rID, err)
168                 }
169                 flt := redmine.IssueFilter{
170                         StatusID:  "open",
171                         ProjectID: fmt.Sprintf("%d", p.ID),
172                         // No values assigned on the following fields. It seems that using
173                         // an empty string is interpreted as 'any value'. The documentation
174                         // isn't clear, but after some trial & error, '!*' seems to do the trick.
175                         // https://www.redmine.org/projects/redmine/wiki/Rest_Issues
176                         ReleaseID: "!*",
177                         VersionID: "!*",
178                         ParentID:  "!*",
179                 }
180                 issues, err := rm.FilteredIssues(&flt)
181                 if err != nil {
182                         fmt.Printf("Error requesting unassigned open issues from project %d: %s", p.ID, err)
183                 }
184                 fmt.Printf("Found %d issues from project '%s' to assign to release '%s'...\n", len(issues), p.Name, r.Name)
185                 for _, issue := range issues {
186                         fmt.Printf("#%d - %s ", issue.ID, issue.Subject)
187                         if !dryRun {
188                                 err = rm.SetRelease(issue, rID)
189                                 if err != nil {
190                                         fmt.Printf("[error]\n")
191                                         log.Fatalf("Error trying to assign issue %d to release %d: %s", issue.ID, rID, err)
192                                 }
193                                 fmt.Printf("[changed]\n")
194                         } else {
195                                 fmt.Printf("[skipped]\n")
196                         }
197                 }
198         },
199 }
200
201 var associateIssueCmd = &cobra.Command{
202         Use:   "associate",
203         Short: "Associate an issue with a release",
204         Long: "Associate an issue with a release.\n" +
205                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
206                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
207         Run: func(cmd *cobra.Command, args []string) {
208                 issueID, err := cmd.Flags().GetInt("issue")
209                 if err != nil {
210                         fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
211                         os.Exit(1)
212                 }
213
214                 releaseID, err := cmd.Flags().GetInt("release")
215                 if err != nil {
216                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
217                         os.Exit(1)
218                 }
219
220                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
221
222                 i, err := redmine.GetIssue(issueID)
223                 if err != nil {
224                         fmt.Printf("%s\n", err.Error())
225                         os.Exit(1)
226                 }
227
228                 var setIt bool
229                 if i.Release == nil || i.Release["release"].ID == 0 {
230                         setIt = true
231                 } else if i.Release["release"].ID != releaseID {
232                         setIt = true
233                 }
234                 if setIt {
235                         err = redmine.SetRelease(*i, releaseID)
236                         if err != nil {
237                                 fmt.Printf("%s\n", err.Error())
238                                 os.Exit(1)
239                         } else {
240                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
241                         }
242                 } else {
243                         fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
244                 }
245         },
246 }
247
248 func checkError(err error) {
249         if err != nil {
250                 fmt.Printf("%s\n", err.Error())
251                 os.Exit(1)
252         }
253 }
254
255 func checkError2(msg string, err error) {
256         if err != nil {
257                 fmt.Printf("%s: %s\n", msg, err.Error())
258                 os.Exit(1)
259         }
260 }
261
262 var findAndAssociateIssuesCmd = &cobra.Command{
263         Use:   "find-and-associate",
264         Short: "Find all issue numbers to associate with a release, and associate them",
265         Long: "Find all issue numbers to associate with a release, and associate them.\n" +
266                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
267                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
268         Run: func(cmd *cobra.Command, args []string) {
269                 previousReleaseTag, err := cmd.Flags().GetString("previous-release-tag")
270                 if err != nil {
271                         log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
272                         return
273                 }
274
275                 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
276                 if err != nil {
277                         log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
278                         return
279                 }
280                 releaseID, err := cmd.Flags().GetInt("release")
281                 if err != nil {
282                         log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
283                         return
284                 }
285
286                 autoSet, err := cmd.Flags().GetBool("auto-set")
287                 if err != nil {
288                         log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
289                         return
290                 }
291                 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
292                 if err != nil {
293                         log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
294                         return
295                 }
296                 arvRepo, err := cmd.Flags().GetString("source-repo")
297                 if err != nil {
298                         log.Fatal(fmt.Errorf("Error getting source-repo value: %s", err))
299                         return
300                 }
301
302                 if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
303                         log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
304                         return
305                 }
306                 if len(newReleaseCommitHash) != 7 && len(newReleaseCommitHash) != 40 {
307                         log.Fatal(fmt.Errorf("The new-release-commit argument is of an unexpected format. Expecting a git commit hash (7 or 40 digits long)"))
308                         return
309                 }
310
311                 // Clone the repo in memory
312
313                 // our own arvados repo won't clone,
314                 //arvRepo := "https://git.arvados.org/arvados.git"
315                 //arvRepo := "https://github.com/arvados/arvados.git"
316
317                 fmt.Println("Cloning " + arvRepo)
318                 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
319                         URL: arvRepo,
320                 })
321                 checkError(err)
322                 fmt.Println("... done")
323                 fmt.Println()
324                 start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
325                 checkError2("repo.ResolveRevision", err)
326                 fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
327                 fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
328                 fmt.Println()
329
330                 // Build the exclusion list
331                 seen := make(map[plumbing.Hash]bool)
332                 excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
333                 checkError2("repo.Log", err)
334                 excludeIter.ForEach(func(c *object.Commit) error {
335                         seen[c.Hash] = true
336                         return nil
337                 })
338
339                 // isValid returns merge commits that are not in the exclusion list
340                 var isValid object.CommitFilter = func(commit *object.Commit) bool {
341                         _, ok := seen[commit.Hash]
342
343                         // use len(commit.ParentHashes) to only get merge commits
344                         return !ok && len(commit.ParentHashes) >= 2
345                 }
346
347                 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
348                 checkError2("repo.CommitObject", err)
349
350                 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
351
352                 issues := make(map[int]bool)
353                 re := regexp.MustCompile(`Merge branch `)
354                 reNotMain := regexp.MustCompile(`Merge branch .(main|master)`)
355                 reIssueRef := regexp.MustCompile(`(Closes|closes|Refs|refs|Fixes|fixes) #(\d+)`)
356                 err = iter.ForEach(func(c *object.Commit) error {
357                         // We have a git commit hook that requires an issue reference on merge commits
358                         if re.MatchString(c.Message) && !reNotMain.MatchString(c.Message) {
359                                 m := reIssueRef.FindStringSubmatch(c.Message)
360                                 if len(m) == 3 {
361                                         i, err := strconv.Atoi(m[2])
362                                         if err != nil {
363                                                 checkError(err)
364                                         }
365                                         issues[i] = true
366                                 }
367                         }
368
369                         if c.Hash == *start {
370                                 return storer.ErrStop
371                         }
372                         return nil
373                 })
374                 checkError(err)
375
376                 // Sort the issue map keys
377                 keys := make([]int, 0, len(issues))
378                 for k := range issues {
379                         keys = append(keys, k)
380                 }
381                 sort.Ints(keys)
382
383                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
384
385                 for c, k := range keys {
386                         fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
387                         // Look up the issue, see if it is already associated with the desired release
388
389                         i, err := r.GetIssue(k)
390                         if err != nil {
391                                 fmt.Println()
392                                 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
393                                 fmt.Println("============================================")
394                                 continue
395                         }
396                         fmt.Println(i.Subject)
397
398                         if i.Release != nil && i.Release["release"].ID != 0 {
399                                 if i.Release["release"].ID == releaseID {
400                                         fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
401                                 } else if !skipReleaseChange {
402                                         fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
403                                         confirm := false
404                                         prompt := &survey.Confirm{
405                                                 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
406                                         }
407                                         err = survey.AskOne(prompt, &confirm)
408                                         if err != nil {
409                                                 log.Fatal(err)
410                                         }
411                                         if confirm {
412                                                 err = r.SetRelease(*i, releaseID)
413                                                 if err != nil {
414                                                         log.Fatal(err)
415                                                 } else {
416                                                         fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
417                                                 }
418                                         }
419                                 } else {
420                                         fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
421                                 }
422                         } else {
423                                 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
424                                 confirm := false
425                                 if !autoSet {
426                                         prompt := &survey.Confirm{
427                                                 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
428                                         }
429                                         err = survey.AskOne(prompt, &confirm)
430                                         if err != nil {
431                                                 return
432                                         }
433                                 }
434                                 if confirm || autoSet {
435                                         err = r.SetRelease(*i, releaseID)
436                                         if err != nil {
437                                                 log.Fatal(err)
438                                         } else {
439                                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
440                                         }
441                                 }
442                         }
443                         fmt.Println("============================================")
444                 }
445         },
446 }
447
448 var createReleaseIssueCmd = &cobra.Command{
449         Use:   "create-release-issue",
450         Short: "Create a release ticket with numbered subtasks for all the steps on the release checklist",
451         Long: "Create a release ticket with numbered subtasks for all the steps on the release checklist.\n" +
452                 "\nThe subtask subjects are read from a file named TASKS in the current directory.\n" +
453                 "\nFinally, a new Redmine release will also be created for the next release.\n" +
454                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
455                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
456         Run: func(cmd *cobra.Command, args []string) {
457                 newReleaseVersion, err := cmd.Flags().GetString("new-release-version")
458                 if err != nil {
459                         log.Fatal(fmt.Errorf("[error] can not get new release version: %s", err))
460                         return
461                 }
462
463                 versionID, err := cmd.Flags().GetInt("sprint")
464                 if err != nil {
465                         log.Fatal(fmt.Errorf("[error] can not convert Redmine sprint (version) ID to integer: %s", err))
466                         return
467                 }
468                 projectName, err := cmd.Flags().GetString("project")
469                 if err != nil {
470                         log.Fatal(fmt.Errorf("[error] can not get Redmine project name: %s", err))
471                         return
472                 }
473
474                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
475
476                 // Does this project exist?
477                 project, err := r.GetProjectByName(projectName)
478                 if err != nil {
479                         log.Fatalf("[error] can not find project with name %s: %s", projectName, err)
480                 }
481
482                 // Is the sprint (aka "version" in redmine) in the correct state?
483                 v, err := r.Version(versionID)
484                 if err != nil {
485                         log.Fatal(fmt.Errorf("[error] can not find sprint with id %d: %s", versionID, err))
486                 }
487                 if v.Status != "open" {
488                         log.Fatal(fmt.Errorf("[error] the sprint must be open; the status of the sprint with id %d is '%s'", v.ID, v.Status))
489                 }
490
491                 i, err := r.FindOrCreateIssue("Release Arvados "+newReleaseVersion, 0, v.ID, project.ID)
492                 if err != nil {
493                         log.Fatal(err)
494                 }
495                 if i.Status.Name != "New" {
496                         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))
497                 }
498
499                 fmt.Printf("[ok] the release ticket is '%s' with ID #%d (%s/issues/%d)\n", i.Subject, i.ID, conf.Endpoint, i.ID)
500
501                 // Get the list of subtasks from the "TASKS" file
502                 tasks, err := os.Open("TASKS")
503                 if err != nil {
504                         log.Fatal(fmt.Errorf("[error] unable to open the \"TASKS\" file: %s", err.Error()))
505                 }
506                 defer tasks.Close()
507
508                 scanner := bufio.NewScanner(tasks)
509                 count := 1
510                 for scanner.Scan() {
511                         task := scanner.Text()
512                         taskIssue, err := r.FindOrCreateIssue(fmt.Sprintf("%d. %s", count, task), i.ID, v.ID, project.ID)
513                         fmt.Printf("[ok] #%d: %d. %s\n", taskIssue.ID, count, task)
514                         count++
515                         if err != nil {
516                                 log.Fatal(fmt.Errorf("Error reading from file: %s", err))
517                         }
518                 }
519
520                 // Create the next release in Redmine
521                 version, err := semver.NewVersion(newReleaseVersion)
522                 if err != nil {
523                         log.Fatalf("Error parsing version: %s", err)
524                 }
525                 nextVersion := version.IncPatch()
526
527                 var release *redmine.Release
528
529                 release, err = r.FindReleaseByName(project.Name, "Arvados "+nextVersion.String())
530                 if err != nil {
531                         log.Fatalf("Error finding release with name %s in project with name %s: %s", release.Name, project.Name, err)
532                 }
533                 if release == nil {
534                         // No release found, create it
535                         release = &redmine.Release{}
536                         release.Name = "Arvados " + nextVersion.String()
537                         release.Sharing = "hierarchy"
538                         release.ReleaseStartDate = time.Now().AddDate(0, 0, 7*1).Format("2006-01-02") // arbitrary choice, 1 week from today
539                         release.ReleaseEndDate = time.Now().AddDate(0, 0, 7*5).Format("2006-01-02")   // also arbitrary, 5 weeks from today
540                         release.ProjectID = project.ID
541                         release.Status = "open"
542                         // Populate Project
543                         tmp, err := r.GetProject(release.ProjectID)
544                         if err != nil {
545                                 log.Fatalf("Unable to find project with ID %d: %s", release.ProjectID, err)
546                         }
547                         release.Project = &redmine.IDName{ID: release.ProjectID, Name: tmp.Name}
548
549                         release, err = r.CreateRelease(*release)
550                         if err != nil {
551                                 log.Fatalf("Unable to create release: %s", err)
552                         }
553                 }
554                 fmt.Printf("[ok] the redmine release object for the next release is '%s' (%s/rb/release/%d)\n", release.Name, conf.Endpoint, release.ID)
555         },
556 }
557
558 var releasesCmd = &cobra.Command{
559         Use:   "releases",
560         Short: "Manage Redmine releases",
561         Long: "Manage Redmine releases.\n" +
562                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
563                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
564 }
565
566 var getReleaseCmd = &cobra.Command{
567         Use:   "get",
568         Short: "get a release",
569         Long: "Get a release.\n" +
570                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
571                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
572         Run: func(cmd *cobra.Command, args []string) {
573                 releaseID, err := cmd.Flags().GetInt("release")
574                 if err != nil {
575                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
576                         os.Exit(1)
577                 }
578
579                 r := redmine.NewClient(conf.Endpoint, conf.Apikey)
580
581                 release, err := r.GetRelease(releaseID)
582                 if err != nil {
583                         log.Fatalf("Error finding release with id %d: %s", releaseID, err)
584                 }
585                 releaseStr, err := json.MarshalIndent(release, "", "  ")
586                 if err != nil {
587                         log.Fatalf("Error decoding release with id %d: %s", releaseID, err)
588                 }
589                 fmt.Println(string(releaseStr))
590
591         },
592 }