OLD | NEW |
| (Empty) |
1 // Copyright (c) 2014 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 /* | |
6 Utilities for interacting with the GoogleCode issue tracker. | |
7 | |
8 Example usage: | |
9 issueTracker := issue_tracker.MakeIssueTraker(myOAuthConfigFile) | |
10 authURL := issueTracker.MakeAuthRequestURL() | |
11 // Visit the authURL to obtain an authorization code. | |
12 issueTracker.UpgradeCode(code) | |
13 // Now issueTracker can be used to retrieve and edit issues. | |
14 */ | |
15 package issue_tracker | |
16 | |
17 import ( | |
18 "bytes" | |
19 "encoding/json" | |
20 "fmt" | |
21 "io/ioutil" | |
22 "net/http" | |
23 "net/url" | |
24 "strconv" | |
25 "strings" | |
26 | |
27 "code.google.com/p/goauth2/oauth" | |
28 ) | |
29 | |
30 // BugPriorities are the possible values for "Priority-*" labels for issues. | |
31 var BugPriorities = []string{"Critical", "High", "Medium", "Low", "Never"} | |
32 | |
33 var apiScope = []string{ | |
34 "https://www.googleapis.com/auth/projecthosting", | |
35 "https://www.googleapis.com/auth/userinfo.email", | |
36 } | |
37 | |
38 const issueApiURL = "https://www.googleapis.com/projecthosting/v2/projects/" | |
39 const issueURL = "https://code.google.com/p/skia/issues/detail?id=" | |
40 const personApiURL = "https://www.googleapis.com/userinfo/v2/me" | |
41 | |
42 // Enum for determining whether a label has been added, removed, or is | |
43 // unchanged. | |
44 const ( | |
45 labelAdded = iota | |
46 labelRemoved | |
47 labelUnchanged | |
48 ) | |
49 | |
50 // loadOAuthConfig reads the OAuth given config file path and returns an | |
51 // appropriate oauth.Config. | |
52 func loadOAuthConfig(oauthConfigFile string) (*oauth.Config, error) { | |
53 errFmt := "failed to read OAuth config file: %s" | |
54 fileContents, err := ioutil.ReadFile(oauthConfigFile) | |
55 if err != nil { | |
56 return nil, fmt.Errorf(errFmt, err) | |
57 } | |
58 var decodedJson map[string]struct { | |
59 AuthURL string `json:"auth_uri"` | |
60 ClientId string `json:"client_id"` | |
61 ClientSecret string `json:"client_secret"` | |
62 TokenURL string `json:"token_uri"` | |
63 } | |
64 if err := json.Unmarshal(fileContents, &decodedJson); err != nil { | |
65 return nil, fmt.Errorf(errFmt, err) | |
66 } | |
67 config, ok := decodedJson["web"] | |
68 if !ok { | |
69 return nil, fmt.Errorf(errFmt, err) | |
70 } | |
71 return &oauth.Config{ | |
72 ClientId: config.ClientId, | |
73 ClientSecret: config.ClientSecret, | |
74 Scope: strings.Join(apiScope, " "), | |
75 AuthURL: config.AuthURL, | |
76 TokenURL: config.TokenURL, | |
77 }, nil | |
78 } | |
79 | |
80 // Issue contains information about an issue. | |
81 type Issue struct { | |
82 Id int `json:"id"` | |
83 Project string `json:"projectId"` | |
84 Title string `json:"title"` | |
85 Labels []string `json:"labels"` | |
86 } | |
87 | |
88 // URL returns the URL of a given issue. | |
89 func (i Issue) URL() string { | |
90 return issueURL + strconv.Itoa(i.Id) | |
91 } | |
92 | |
93 // IssueList represents a list of issues from the IssueTracker. | |
94 type IssueList struct { | |
95 TotalResults int `json:"totalResults"` | |
96 Items []*Issue `json:"items"` | |
97 } | |
98 | |
99 // IssueTracker is the primary point of contact with the issue tracker, | |
100 // providing methods for authenticating to and interacting with it. | |
101 type IssueTracker struct { | |
102 OAuthConfig *oauth.Config | |
103 OAuthTransport *oauth.Transport | |
104 } | |
105 | |
106 // MakeIssueTracker creates and returns an IssueTracker with authentication | |
107 // configuration from the given authConfigFile. | |
108 func MakeIssueTracker(authConfigFile string, redirectURL string) (*IssueTracker,
error) { | |
109 oauthConfig, err := loadOAuthConfig(authConfigFile) | |
110 if err != nil { | |
111 return nil, fmt.Errorf( | |
112 "failed to create IssueTracker: %s", err) | |
113 } | |
114 oauthConfig.RedirectURL = redirectURL | |
115 return &IssueTracker{ | |
116 OAuthConfig: oauthConfig, | |
117 OAuthTransport: &oauth.Transport{Config: oauthConfig}, | |
118 }, nil | |
119 } | |
120 | |
121 // MakeAuthRequestURL returns an authentication request URL which can be used | |
122 // to obtain an authorization code via user sign-in. | |
123 func (it IssueTracker) MakeAuthRequestURL() string { | |
124 // NOTE: Need to add XSRF protection if we ever want to run this on a pu
blic | |
125 // server. | |
126 return it.OAuthConfig.AuthCodeURL(it.OAuthConfig.RedirectURL) | |
127 } | |
128 | |
129 // IsAuthenticated determines whether the IssueTracker has sufficient | |
130 // permissions to retrieve and edit Issues. | |
131 func (it IssueTracker) IsAuthenticated() bool { | |
132 return it.OAuthTransport.Token != nil | |
133 } | |
134 | |
135 // UpgradeCode exchanges the single-use authorization code, obtained by | |
136 // following the URL obtained from IssueTracker.MakeAuthRequestURL, for a | |
137 // multi-use, session token. This is required before IssueTracker can retrieve | |
138 // and edit issues. | |
139 func (it *IssueTracker) UpgradeCode(code string) error { | |
140 token, err := it.OAuthTransport.Exchange(code) | |
141 if err == nil { | |
142 it.OAuthTransport.Token = token | |
143 return nil | |
144 } else { | |
145 return fmt.Errorf( | |
146 "failed to exchange single-user auth code: %s", err) | |
147 } | |
148 } | |
149 | |
150 // GetLoggedInUser retrieves the email address of the authenticated user. | |
151 func (it IssueTracker) GetLoggedInUser() (string, error) { | |
152 errFmt := "error retrieving user email: %s" | |
153 if !it.IsAuthenticated() { | |
154 return "", fmt.Errorf(errFmt, "User is not authenticated!") | |
155 } | |
156 resp, err := it.OAuthTransport.Client().Get(personApiURL) | |
157 if err != nil { | |
158 return "", fmt.Errorf(errFmt, err) | |
159 } | |
160 defer resp.Body.Close() | |
161 body, _ := ioutil.ReadAll(resp.Body) | |
162 if resp.StatusCode != http.StatusOK { | |
163 return "", fmt.Errorf(errFmt, fmt.Sprintf( | |
164 "user data API returned code %d: %v", resp.StatusCode, s
tring(body))) | |
165 } | |
166 userInfo := struct { | |
167 Email string `json:"email"` | |
168 }{} | |
169 if err := json.Unmarshal(body, &userInfo); err != nil { | |
170 return "", fmt.Errorf(errFmt, err) | |
171 } | |
172 return userInfo.Email, nil | |
173 } | |
174 | |
175 // GetBug retrieves the Issue with the given ID from the IssueTracker. | |
176 func (it IssueTracker) GetBug(project string, id int) (*Issue, error) { | |
177 errFmt := fmt.Sprintf("error retrieving issue %d: %s", id, "%s") | |
178 if !it.IsAuthenticated() { | |
179 return nil, fmt.Errorf(errFmt, "user is not authenticated!") | |
180 } | |
181 requestURL := issueApiURL + project + "/issues/" + strconv.Itoa(id) | |
182 resp, err := it.OAuthTransport.Client().Get(requestURL) | |
183 if err != nil { | |
184 return nil, fmt.Errorf(errFmt, err) | |
185 } | |
186 defer resp.Body.Close() | |
187 body, _ := ioutil.ReadAll(resp.Body) | |
188 if resp.StatusCode != http.StatusOK { | |
189 return nil, fmt.Errorf(errFmt, fmt.Sprintf( | |
190 "issue tracker returned code %d:%v", resp.StatusCode, st
ring(body))) | |
191 } | |
192 var issue Issue | |
193 if err := json.Unmarshal(body, &issue); err != nil { | |
194 return nil, fmt.Errorf(errFmt, err) | |
195 } | |
196 return &issue, nil | |
197 } | |
198 | |
199 // GetBugs retrieves all Issues with the given owner from the IssueTracker, | |
200 // returning an IssueList. | |
201 func (it IssueTracker) GetBugs(project string, owner string) (*IssueList, error)
{ | |
202 errFmt := "error retrieving issues: %s" | |
203 if !it.IsAuthenticated() { | |
204 return nil, fmt.Errorf(errFmt, "user is not authenticated!") | |
205 } | |
206 params := map[string]string{ | |
207 "owner": url.QueryEscape(owner), | |
208 "can": "open", | |
209 "maxResults": "9999", | |
210 } | |
211 requestURL := issueApiURL + project + "/issues?" | |
212 first := true | |
213 for k, v := range params { | |
214 if first { | |
215 first = false | |
216 } else { | |
217 requestURL += "&" | |
218 } | |
219 requestURL += k + "=" + v | |
220 } | |
221 resp, err := it.OAuthTransport.Client().Get(requestURL) | |
222 if err != nil { | |
223 return nil, fmt.Errorf(errFmt, err) | |
224 } | |
225 defer resp.Body.Close() | |
226 body, _ := ioutil.ReadAll(resp.Body) | |
227 if resp.StatusCode != http.StatusOK { | |
228 return nil, fmt.Errorf(errFmt, fmt.Sprintf( | |
229 "issue tracker returned code %d:%v", resp.StatusCode, st
ring(body))) | |
230 } | |
231 | |
232 var bugList IssueList | |
233 if err := json.Unmarshal(body, &bugList); err != nil { | |
234 return nil, fmt.Errorf(errFmt, err) | |
235 } | |
236 return &bugList, nil | |
237 } | |
238 | |
239 // SubmitIssueChanges creates a comment on the given Issue which modifies it | |
240 // according to the contents of the passed-in Issue struct. | |
241 func (it IssueTracker) SubmitIssueChanges(issue *Issue, comment string) error { | |
242 errFmt := "Error updating issue " + strconv.Itoa(issue.Id) + ": %s" | |
243 if !it.IsAuthenticated() { | |
244 return fmt.Errorf(errFmt, "user is not authenticated!") | |
245 } | |
246 oldIssue, err := it.GetBug(issue.Project, issue.Id) | |
247 if err != nil { | |
248 return fmt.Errorf(errFmt, err) | |
249 } | |
250 postData := struct { | |
251 Content string `json:"content"` | |
252 Updates struct { | |
253 Title *string `json:"summary"` | |
254 Labels []string `json:"labels"` | |
255 } `json:"updates"` | |
256 }{ | |
257 Content: comment, | |
258 } | |
259 if issue.Title != oldIssue.Title { | |
260 postData.Updates.Title = &issue.Title | |
261 } | |
262 // TODO(borenet): Add other issue attributes, eg. Owner. | |
263 labels := make(map[string]int) | |
264 for _, label := range issue.Labels { | |
265 labels[label] = labelAdded | |
266 } | |
267 for _, label := range oldIssue.Labels { | |
268 if _, ok := labels[label]; ok { | |
269 labels[label] = labelUnchanged | |
270 } else { | |
271 labels[label] = labelRemoved | |
272 } | |
273 } | |
274 labelChanges := make([]string, 0) | |
275 for labelName, present := range labels { | |
276 if present == labelRemoved { | |
277 labelChanges = append(labelChanges, "-"+labelName) | |
278 } else if present == labelAdded { | |
279 labelChanges = append(labelChanges, labelName) | |
280 } | |
281 } | |
282 if len(labelChanges) > 0 { | |
283 postData.Updates.Labels = labelChanges | |
284 } | |
285 | |
286 postBytes, err := json.Marshal(&postData) | |
287 if err != nil { | |
288 return fmt.Errorf(errFmt, err) | |
289 } | |
290 requestURL := issueApiURL + issue.Project + "/issues/" + | |
291 strconv.Itoa(issue.Id) + "/comments" | |
292 resp, err := it.OAuthTransport.Client().Post( | |
293 requestURL, "application/json", bytes.NewReader(postBytes)) | |
294 if err != nil { | |
295 return fmt.Errorf(errFmt, err) | |
296 } | |
297 defer resp.Body.Close() | |
298 body, _ := ioutil.ReadAll(resp.Body) | |
299 if resp.StatusCode != http.StatusOK { | |
300 return fmt.Errorf(errFmt, fmt.Sprintf( | |
301 "Issue tracker returned code %d:%v", resp.StatusCode, st
ring(body))) | |
302 } | |
303 return nil | |
304 } | |
OLD | NEW |