18300: Merge branch 'main' into 18300-release-ticket-association
[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         "fmt"
9         "log"
10         "os"
11         "regexp"
12         "sort"
13         "strconv"
14
15         "git.arvados.org/arvados-dev.git/lib/redmine"
16         survey "github.com/AlecAivazis/survey/v2"
17         "github.com/go-git/go-git/v5"
18         "github.com/go-git/go-git/v5/plumbing"
19         "github.com/go-git/go-git/v5/plumbing/object"
20         "github.com/go-git/go-git/v5/plumbing/storer"
21         "github.com/go-git/go-git/v5/storage/memory"
22         "github.com/spf13/cobra"
23 )
24
25 func init() {
26         rootCmd.AddCommand(redmineCmd)
27         redmineCmd.AddCommand(issuesCmd)
28
29         associateIssueCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
30         err := associateIssueCmd.MarkFlagRequired("release")
31         if err != nil {
32                 log.Fatalf(err.Error())
33         }
34         associateIssueCmd.Flags().IntP("issue", "i", 0, "Redmine issue ID")
35         err = associateIssueCmd.MarkFlagRequired("issue")
36         if err != nil {
37                 log.Fatalf(err.Error())
38         }
39         issuesCmd.AddCommand(associateIssueCmd)
40
41         findAndAssociateIssuesCmd.Flags().IntP("release", "r", 0, "Redmine release ID")
42         err = findAndAssociateIssuesCmd.MarkFlagRequired("release")
43         if err != nil {
44                 log.Fatalf(err.Error())
45         }
46         findAndAssociateIssuesCmd.Flags().StringP("previous-release-tag", "p", "", "Semantic version number of the previous release")
47         err = findAndAssociateIssuesCmd.MarkFlagRequired("previous-release-tag")
48         if err != nil {
49                 log.Fatalf(err.Error())
50         }
51         findAndAssociateIssuesCmd.Flags().StringP("new-release-commit", "n", "", "Git commit for the new release")
52         err = findAndAssociateIssuesCmd.MarkFlagRequired("new-release-commit")
53         if err != nil {
54                 log.Fatalf(err.Error())
55         }
56         findAndAssociateIssuesCmd.Flags().BoolP("auto-set", "a", false, "Associate issues without existing release without prompting")
57         findAndAssociateIssuesCmd.Flags().BoolP("skip-release-change", "s", false, "Skip issues already assigned to another release (do not prompt)")
58         issuesCmd.AddCommand(findAndAssociateIssuesCmd)
59 }
60
61 var redmineCmd = &cobra.Command{
62         Use:   "redmine",
63         Short: "Manage Redmine",
64         Long: "Manage Redmine.\n" +
65                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
66                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
67         PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
68                 if conf.Endpoint == "" {
69                         cmd.Help()
70                         fmt.Println()
71                         fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
72                         os.Exit(1)
73                 }
74                 if conf.Apikey == "" {
75                         cmd.Help()
76                         fmt.Println()
77                         fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
78                         os.Exit(1)
79                 }
80                 return nil
81         },
82 }
83
84 var issuesCmd = &cobra.Command{
85         Use:   "issues",
86         Short: "Manage Redmine issues",
87         Long: "Manage Redmine issues.\n" +
88                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
89                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
90 }
91
92 var associateIssueCmd = &cobra.Command{
93         Use:   "associate",
94         Short: "Associate an issue with a release",
95         Long: "Associate an issue with a release.\n" +
96                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
97                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
98         Run: func(cmd *cobra.Command, args []string) {
99                 issueID, err := cmd.Flags().GetInt("issue")
100                 if err != nil {
101                         fmt.Printf("Error converting Redmine issue ID to integer: %s", err)
102                         os.Exit(1)
103                 }
104
105                 releaseID, err := cmd.Flags().GetInt("release")
106                 if err != nil {
107                         fmt.Printf("Error converting Redmine release ID to integer: %s", err)
108                         os.Exit(1)
109                 }
110
111                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
112
113                 i, err := redmine.GetIssue(issueID)
114                 if err != nil {
115                         fmt.Printf("%s\n", err.Error())
116                         os.Exit(1)
117                 }
118
119                 var setIt bool
120                 if i.Release == nil || i.Release["release"].ID == 0 {
121                         setIt = true
122                 } else if i.Release["release"].ID != releaseID {
123                         setIt = true
124                 }
125                 if setIt {
126                         err = redmine.SetRelease(*i, releaseID)
127                         if err != nil {
128                                 fmt.Printf("%s\n", err.Error())
129                                 os.Exit(1)
130                         } else {
131                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
132                         }
133                 } else {
134                         fmt.Printf("[ok] release for issue %d was already set to %d, not updating\n", i.ID, i.Release["release"].ID)
135                 }
136         },
137 }
138
139 func checkError(err error) {
140         if err != nil {
141                 fmt.Printf("%s\n", err.Error())
142                 os.Exit(1)
143         }
144 }
145
146 var findAndAssociateIssuesCmd = &cobra.Command{
147         Use:   "find-and-associate",
148         Short: "Find all issue numbers to associate with a release, and associate them",
149         Long: "Find all issue numbers to associate with a release, and associate them.\n" +
150                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
151                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
152         Run: func(cmd *cobra.Command, args []string) {
153                 previousReleaseTag, err := cmd.Flags().GetString("previous-release-tag")
154                 if err != nil {
155                         log.Fatal(fmt.Errorf("Error retrieving previous release: %s", err))
156                         return
157                 }
158
159                 newReleaseCommitHash, err := cmd.Flags().GetString("new-release-commit")
160                 if err != nil {
161                         log.Fatal(fmt.Errorf("Error retrieving new release: %s", err))
162                         return
163                 }
164                 releaseID, err := cmd.Flags().GetInt("release")
165                 if err != nil {
166                         log.Fatal(fmt.Errorf("Error converting Redmine release ID to integer: %s", err))
167                         return
168                 }
169
170                 autoSet, err := cmd.Flags().GetBool("auto-set")
171                 if err != nil {
172                         log.Fatal(fmt.Errorf("Error getting auto-set value: %s", err))
173                         return
174                 }
175                 skipReleaseChange, err := cmd.Flags().GetBool("skip-release-change")
176                 if err != nil {
177                         log.Fatal(fmt.Errorf("Error getting skip-release-change value: %s", err))
178                         return
179                 }
180
181                 if len(previousReleaseTag) < 5 || len(previousReleaseTag) > 8 {
182                         log.Fatal(fmt.Errorf("The previous-release-tag argument is of an unexpected format. Expecting a semantic version (e.g. 2.3.0)"))
183                         return
184                 }
185                 if len(newReleaseCommitHash) != 7 && len(newReleaseCommitHash) != 40 {
186                         log.Fatal(fmt.Errorf("The new-release-commit argument is of an unexpected format. Expecting a git commit hash (7 or 40 digits long)"))
187                         return
188                 }
189
190                 // Clone the repo in memory
191                 fmt.Println("Cloning https://github.com/arvados/arvados.git")
192                 repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
193                         URL: "https://github.com/arvados/arvados.git",
194                 })
195                 checkError(err)
196                 fmt.Println("... done")
197                 fmt.Println()
198                 start, err := repo.ResolveRevision(plumbing.Revision("refs/tags/" + previousReleaseTag))
199                 checkError(err)
200                 fmt.Printf("previous-release-tag: %s (%s)\n", previousReleaseTag, start)
201                 fmt.Printf("new-release-commit: %s\n", newReleaseCommitHash)
202                 fmt.Println()
203
204                 // Build the exclusion list
205                 seen := make(map[plumbing.Hash]bool)
206                 excludeIter, err := repo.Log(&git.LogOptions{From: *start, Order: git.LogOrderCommitterTime})
207                 checkError(err)
208                 excludeIter.ForEach(func(c *object.Commit) error {
209                         seen[c.Hash] = true
210                         return nil
211                 })
212
213                 // isValid returns merge commits that are not in the exclusion list
214                 var isValid object.CommitFilter = func(commit *object.Commit) bool {
215                         _, ok := seen[commit.Hash]
216
217                         // use len(commit.ParentHashes) to only get merge commits
218                         return !ok && len(commit.ParentHashes) >= 2
219                 }
220
221                 headCommit, err := repo.CommitObject(plumbing.NewHash(newReleaseCommitHash))
222                 checkError(err)
223
224                 iter := object.NewFilterCommitIter(headCommit, &isValid, nil)
225
226                 issues := make(map[int]bool)
227                 re := regexp.MustCompile(`Merge branch `)
228                 reNotMain := regexp.MustCompile(`Merge branch .(main|master)`)
229                 reIssueRef := regexp.MustCompile(`(Closes|closes|Refs|refs|Fixes|fixes) #(\d+)`)
230                 err = iter.ForEach(func(c *object.Commit) error {
231                         // We have a git commit hook that requires an issue reference on merge commits
232                         if re.MatchString(c.Message) && !reNotMain.MatchString(c.Message) {
233                                 m := reIssueRef.FindStringSubmatch(c.Message)
234                                 if len(m) == 3 {
235                                         i, err := strconv.Atoi(m[2])
236                                         if err != nil {
237                                                 checkError(err)
238                                         }
239                                         issues[i] = true
240                                 }
241                         }
242
243                         if c.Hash == *start {
244                                 return storer.ErrStop
245                         }
246                         return nil
247                 })
248                 checkError(err)
249
250                 // Sort the issue map keys
251                 keys := make([]int, 0, len(issues))
252                 for k := range issues {
253                         keys = append(keys, k)
254                 }
255                 sort.Ints(keys)
256
257                 redmine := redmine.NewClient(conf.Endpoint, conf.Apikey)
258
259                 for c, k := range keys {
260                         fmt.Printf("%d (%d/%d): ", k, c+1, len(keys))
261                         // Look up the issue, see if it is already associated with the desired release
262
263                         i, err := redmine.GetIssue(k)
264                         if err != nil {
265                                 fmt.Println()
266                                 fmt.Printf("[error] unable to retrieve issue: %s\n", err.Error())
267                                 fmt.Println("============================================")
268                                 continue
269                         }
270                         fmt.Println(i.Subject)
271
272                         if i.Release != nil && i.Release["release"].ID != 0 {
273                                 if i.Release["release"].ID == releaseID {
274                                         fmt.Printf("[ok] release is already set to %d, nothing to do\n", i.Release["release"].ID)
275                                 } else if !skipReleaseChange {
276                                         fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
277                                         confirm := false
278                                         prompt := &survey.Confirm{
279                                                 Message: fmt.Sprintf("release is set to %d, do you want to change it to %d ?", i.Release["release"].ID, releaseID),
280                                         }
281                                         err = survey.AskOne(prompt, &confirm)
282                                         if err != nil {
283                                                 log.Fatal(err)
284                                         }
285                                         if confirm {
286                                                 err = redmine.SetRelease(*i, releaseID)
287                                                 if err != nil {
288                                                         log.Fatal(err)
289                                                 } else {
290                                                         fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
291                                                 }
292                                         }
293                                 } else {
294                                         fmt.Printf("[ok] release is set to %d, not changing it to %d\n", i.Release["release"].ID, releaseID)
295                                 }
296                         } else {
297                                 fmt.Printf("%s/issues/%d\n", conf.Endpoint, k)
298                                 confirm := false
299                                 if !autoSet {
300                                         prompt := &survey.Confirm{
301                                                 Message: fmt.Sprintf("Release is not set, do you want to set it to %d ?", releaseID),
302                                         }
303                                         err = survey.AskOne(prompt, &confirm)
304                                         if err != nil {
305                                                 return
306                                         }
307                                 }
308                                 if confirm || autoSet {
309                                         err = redmine.SetRelease(*i, releaseID)
310                                         if err != nil {
311                                                 log.Fatal(err)
312                                         } else {
313                                                 fmt.Printf("[changed] release for issue %d set to %d\n", i.ID, releaseID)
314                                         }
315                                 }
316                         }
317                         fmt.Println("============================================")
318                 }
319         },
320 }