Merge branch '21521-aptly-flock'
[arvados-dev.git] / lib / redmine / issues.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: Apache-2.0
4
5 // Somewhat inspired by https://github.com/mattn/go-redmine (MIT licensed)
6
7 package redmine
8
9 import (
10         "encoding/json"
11         "fmt"
12         "net/url"
13         "strconv"
14         "strings"
15 )
16
17 // In read operations the Redmine API returns ID fields like ProjectID.
18 // When updating or creating an object, it wants a Project field.
19 // This struct represents both for convenience.
20 type Issue struct {
21         ID             int                `json:"id"`
22         Subject        string             `json:"subject"`
23         Description    string             `json:"description,omitempty"`
24         ProjectID      int                `json:"project_id,omitempty"`
25         Project        *IDName            `json:"project,omitempty"`
26         ParentIssueID  int                `json:"parent_issue_id,omitempty"`
27         Parent         *ID                `json:"parent,omitempty"`
28         StatusID       int                `json:"status_id,omitempty"`
29         Status         *IDName            `json:"status,omitempty"`
30         FixedVersionID int                `json:"fixed_version_id,omitempty"`
31         FixedVersion   *IDName            `json:"fixed_version,omitempty"`
32         ReleaseID      int                `json:"release_id,omitempty"`
33         Release        map[string]*IDName `json:"release,omitempty"`
34         TrackerID      int                `json:"tracker_id,omitempty"`
35         Tracker        *IDName            `json:"tracker,omitempty"`
36         PriorityID     int                `json:"priority_id,omitempty"`
37         Priority       *IDName            `json:"priority,omitempty"`
38         CategoryID     int                `json:"category_id,omitempty"`
39         Category       *IDName            `json:"category,omitempty"`
40         AssignedToID   int                `json:"assigned_to_id,omitempty"`
41         AssignedTo     *IDName            `json:"assigned_to,omitempty"`
42         WatcherUserIDs []int              `json:"watcher_user_ids,omitempty"`
43         Watchers       []*IDName          `json:"watchers,omitempty"`
44         IsPrivate      bool               `json:"is_private,omitempty"`
45         EstimatedHours float64            `json:"estimated_hours,omitempty"`
46         Notes          string             `json:"notes,omitempty"`
47 }
48
49 type IssueFilter struct {
50         ProjectID string
51         StatusID  string
52         Subject   string
53         ParentID  string
54         VersionID string
55         ReleaseID string
56 }
57
58 type issuesResult struct {
59         Issues     []Issue `json:"issues"`
60         TotalCount uint    `json:"total_count"`
61         Offset     uint    `json:"offset"`
62         Limit      uint    `json:"limit"`
63 }
64
65 type issueWrapper struct {
66         Issue Issue `json:"issue"`
67 }
68
69 // issueFilters converts an *IssueFilter into a slice of filter strings
70 func issueFilters(issueFilter *IssueFilter) []string {
71         var filterParameters []string
72
73         if issueFilter == nil {
74                 return filterParameters
75         }
76
77         if len(issueFilter.ProjectID) > 0 {
78                 filterParameters = append(filterParameters, fmt.Sprintf("project_id=%v", issueFilter.ProjectID))
79         }
80         if len(issueFilter.StatusID) > 0 {
81                 filterParameters = append(filterParameters, fmt.Sprintf("status_id=%v", issueFilter.StatusID))
82         }
83         if len(issueFilter.ParentID) > 0 {
84                 filterParameters = append(filterParameters, fmt.Sprintf("parent_id=%v", issueFilter.ParentID))
85         }
86         if len(issueFilter.Subject) > 0 {
87                 filterParameters = append(filterParameters, fmt.Sprintf("subject=~%v", issueFilter.Subject))
88         }
89         if len(issueFilter.VersionID) > 0 {
90                 filterParameters = append(filterParameters, fmt.Sprintf("fixed_version_id=%v", issueFilter.VersionID))
91         }
92         if len(issueFilter.ReleaseID) > 0 {
93                 filterParameters = append(filterParameters, fmt.Sprintf("release_id=%v", issueFilter.ReleaseID))
94         }
95
96         return filterParameters
97 }
98
99 // FilteredIssues returns a slice of issues that matches the f criteria
100 // This function handles pagination internally, so it could return a lot
101 // of results at once.
102 func (c *Client) FilteredIssues(f *IssueFilter) ([]Issue, error) {
103         s := issueFilters(f)
104
105         var issues []Issue
106         var offset int
107         // Get 100 results at once (the default is 25)
108         limit := 100
109         for {
110                 parameters := append(s, fmt.Sprintf("offset=%d", offset), fmt.Sprintf("limit=%d", limit))
111                 res, err := c.Get("/issues.json?" + strings.Join(parameters, "&"))
112                 if err != nil {
113                         return nil, err
114                 }
115                 defer res.Body.Close()
116
117                 var r issuesResult
118                 err = responseHelper(res, &r, 200)
119                 if err != nil {
120                         return nil, err
121                 }
122                 issues = append(issues, r.Issues...)
123                 if r.Offset+uint(len(r.Issues)) >= r.TotalCount {
124                         break
125                 }
126                 offset += limit
127         }
128
129         return issues, nil
130 }
131
132 // CreateIssue creates a redmine issue
133 func (c *Client) CreateIssue(issue Issue) (*Issue, error) {
134         var ir issueWrapper
135         ir.Issue = issue
136         s, err := json.Marshal(ir)
137         if err != nil {
138                 return nil, err
139         }
140         res, err := c.Post("/issues.json", string(s))
141         if err != nil {
142                 return nil, err
143         }
144         defer res.Body.Close()
145
146         var r issueWrapper
147         err = responseHelper(res, &r, 201)
148         if err != nil {
149                 return nil, err
150         }
151         return &r.Issue, nil
152 }
153
154 // GetIssue retrieves a redmine Issue object by id
155 func (c *Client) GetIssue(ID int) (*Issue, error) {
156         res, err := c.Get("/issues/" + strconv.Itoa(ID) + ".json")
157         if err != nil {
158                 return nil, err
159         }
160         defer res.Body.Close()
161
162         if res.StatusCode == 404 {
163                 return nil, fmt.Errorf("Issue with id %d not found", ID)
164         }
165         var r issueWrapper
166         err = responseHelper(res, &r, 200)
167         if err != nil {
168                 return nil, err
169         }
170         return &r.Issue, nil
171 }
172
173 // UpdateIssue updates a redmine issue
174 func (c *Client) UpdateIssue(issue Issue) error {
175         var ir issueWrapper
176         issue.ProjectID = issue.Project.ID
177         ir.Issue = issue
178         s, err := json.Marshal(ir)
179         if err != nil {
180                 return err
181         }
182         res, err := c.Put("/issues/"+strconv.Itoa(issue.ID)+".json", string(s))
183         if err != nil {
184                 return err
185         }
186         defer res.Body.Close()
187         if res.StatusCode == 404 {
188                 return fmt.Errorf("Issue with id %d not found", issue.ID)
189         }
190
191         return responseHelper(res, nil, 204)
192 }
193
194 // FindOrCreateIssue finds or creates an issue with a given subject, parentID, versionID and projectID
195 func (c *Client) FindOrCreateIssue(subject string, parentID int, versionID int, projectID int) (Issue, error) {
196         var f IssueFilter
197         var issue Issue
198         f.Subject = url.QueryEscape(subject)
199         if parentID != 0 {
200                 f.ParentID = strconv.Itoa(parentID)
201         }
202         if projectID != 0 {
203                 f.ProjectID = strconv.Itoa(projectID)
204         }
205         f.StatusID = "*"
206         issues, err := c.FilteredIssues(&f)
207         if err != nil {
208                 return issue, err
209         }
210         if len(issues) > 0 {
211                 // Issue found, return it
212                 return issues[0], err
213         }
214
215         // Create new issue
216         issue.ProjectID = projectID
217         issue.FixedVersionID = versionID
218         issue.Subject = subject
219         if parentID != 0 {
220                 issue.ParentIssueID = parentID
221         }
222
223         i, err := c.CreateIssue(issue)
224         if err != nil {
225                 return Issue{}, err
226         }
227         return *i, err
228 }
229
230 // SetRelease updates the release for an issue
231 func (c *Client) SetRelease(issue Issue, release int) error {
232         issue.ReleaseID = release
233         issue.Release = nil
234         return c.UpdateIssue(issue)
235 }
236
237 // SetSprint updates the sprint (fixed_version) for an issue
238 func (c *Client) SetSprint(issue Issue, version int) error {
239         issue.FixedVersionID = version
240         issue.FixedVersion = nil
241         return c.UpdateIssue(issue)
242 }
243
244 // SetStatus updates the status for an issue
245 func (c *Client) SetStatus(issue Issue, status int) error {
246         issue.StatusID = status
247         issue.Status = nil
248         return c.UpdateIssue(issue)
249 }