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