Chromium Code Reviews| 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 "code.google.com/p/goauth2/oauth" | |
| 20 "encoding/json" | |
| 21 "errors" | |
| 22 "fmt" | |
| 23 "io/ioutil" | |
| 24 "net/http" | |
| 25 "net/url" | |
| 26 "strconv" | |
| 27 "strings" | |
| 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 = iota | |
| 47 labelUnchanged = iota | |
|
borenet
2014/05/07 22:27:38
Changed for clarity.
jcgregorio
2014/05/08 15:11:57
No need to repeat iota, see http://golang.org/doc/
borenet
2014/05/09 15:40:11
Done.
| |
| 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 err = json.Unmarshal(fileContents, &decodedJson) | |
| 65 if err != nil { | |
| 66 return nil, fmt.Errorf(errFmt, err) | |
| 67 } | |
| 68 config, ok := decodedJson["web"] | |
| 69 if !ok { | |
| 70 return nil, fmt.Errorf(errFmt, err) | |
| 71 } | |
| 72 return &oauth.Config{ | |
| 73 ClientId: config.ClientId, | |
| 74 ClientSecret: config.ClientSecret, | |
| 75 Scope: strings.Join(apiScope, " "), | |
| 76 AuthURL: config.AuthURL, | |
| 77 TokenURL: config.TokenURL, | |
| 78 AccessType: "offline", | |
|
borenet
2014/05/07 22:27:38
Not sure I like this. I think I'd rather just hav
jcgregorio
2014/05/08 15:11:57
That's reasonable given the tool, so "online" woul
borenet
2014/05/09 15:40:11
"offline" is the zero-value, so I just removed thi
borenet
2014/05/12 20:05:51
I meant "online" here.
| |
| 79 }, nil | |
| 80 } | |
| 81 | |
| 82 // Issue contains information about an issue. | |
| 83 type Issue struct { | |
| 84 Id int `json:"id"` | |
| 85 Project string `json:"projectId"` | |
| 86 Title string `json:"title"` | |
| 87 Labels []string `json:"labels"` | |
| 88 } | |
| 89 | |
| 90 // URL returns the URL of a given issue. | |
| 91 func (i Issue) URL() string { | |
| 92 return issueURL + strconv.Itoa(i.Id) | |
| 93 } | |
| 94 | |
| 95 // IssueList represents a list of issues from the IssueTracker. | |
| 96 type IssueList struct { | |
| 97 TotalResults int `json:"totalResults"` | |
| 98 Items []*Issue `json:"items"` | |
| 99 } | |
| 100 | |
| 101 // IssueTracker is the primary point of contact with the issue tracker, | |
| 102 // providing methods for authenticating to and interacting with it. | |
| 103 type IssueTracker struct { | |
| 104 oauthConfig *oauth.Config | |
| 105 oauthTransport *oauth.Transport | |
| 106 } | |
| 107 | |
| 108 // MakeIssueTracker creates and returns an IssueTracker with authentication | |
| 109 // configuration from the given authConfigFile. | |
| 110 func MakeIssueTracker(authConfigFile string) (*IssueTracker, error) { | |
| 111 oauthConfig, err := loadOAuthConfig(authConfigFile) | |
| 112 if err != nil { | |
| 113 return nil, fmt.Errorf( | |
| 114 "failed to create IssueTracker: %s", err) | |
| 115 } | |
| 116 return &IssueTracker{ | |
| 117 oauthConfig: oauthConfig, | |
| 118 oauthTransport: &oauth.Transport{Config: oauthConfig}, | |
| 119 }, nil | |
| 120 } | |
| 121 | |
| 122 // MakeAuthRequestURL returns an authentication request URL which can be used | |
| 123 // to obtain an authorization code via user sign-in. | |
| 124 func (it *IssueTracker) MakeAuthRequestURL(redirectURL string) string { | |
| 125 it.oauthConfig.RedirectURL = redirectURL | |
| 126 return it.oauthConfig.AuthCodeURL(redirectURL) | |
|
jcgregorio
2014/05/08 15:11:57
I didn't realize how bad the goauth2 library is, i
borenet
2014/05/09 15:40:11
So my intention was to make this something which *
| |
| 127 } | |
| 128 | |
| 129 // IsAuthenticated determines whether the IssueTracker has sufficient | |
| 130 // permissions to retrieve and edit Issues. | |
| 131 func (it *IssueTracker) IsAuthenticated() bool { | |
|
jcgregorio
2014/05/08 15:11:57
it IssueTracker
Check all instances, if the call
borenet
2014/05/09 15:40:11
Done.
| |
| 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("failed to exchange single-user auth code: %s" , err) | |
| 146 } | |
| 147 } | |
| 148 | |
| 149 // GetLoggedInUser retrieves the email address of the authenticated user. | |
| 150 func (it *IssueTracker) GetLoggedInUser() (string, error) { | |
| 151 errFmt := "error retrieving user email: %s" | |
| 152 if !it.IsAuthenticated() { | |
| 153 return "", fmt.Errorf( | |
| 154 errFmt, errors.New("User is not authenticated!")) | |
|
jcgregorio
2014/05/08 15:11:57
fmt.Errorf(errFmt, errors.New(
can be more cleanl
borenet
2014/05/09 15:40:11
Done.
| |
| 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, errors.New(fmt.Sprintf( | |
| 164 "user data API returned code %d: %v", | |
| 165 resp.StatusCode, string(body)))) | |
| 166 } | |
| 167 userInfo := struct { | |
| 168 Email string `json:"email"` | |
| 169 }{} | |
| 170 err = json.Unmarshal(body, &userInfo) | |
| 171 if err != nil { | |
| 172 return "", fmt.Errorf(errFmt, err) | |
| 173 } | |
| 174 return userInfo.Email, nil | |
| 175 } | |
| 176 | |
| 177 // GetBug retrieves the Issue with the given ID from the IssueTracker. | |
| 178 func (it *IssueTracker) GetBug(project string, id int) (*Issue, error) { | |
| 179 errFmt := fmt.Sprintf("error retrieving issue %d: %s", id) | |
|
jcgregorio
2014/05/08 15:11:57
I don't think this works the way you want it to, s
borenet
2014/05/09 15:40:11
Modified it in a slightly dumb way.
| |
| 180 if !it.IsAuthenticated() { | |
| 181 return nil, fmt.Errorf( | |
| 182 errFmt, errors.New("user is not authenticated!")) | |
| 183 } | |
| 184 requestURL := issueApiURL + project + "/issues/" + strconv.Itoa(id) | |
| 185 resp, err := it.oauthTransport.Client().Get(requestURL) | |
| 186 if err != nil { | |
| 187 return nil, fmt.Errorf(errFmt, err) | |
| 188 } | |
| 189 defer resp.Body.Close() | |
| 190 body, _ := ioutil.ReadAll(resp.Body) | |
| 191 if resp.StatusCode != http.StatusOK { | |
| 192 return nil, fmt.Errorf(errFmt, errors.New(fmt.Sprintf( | |
| 193 "issue tracker returned code %d:%v", | |
| 194 resp.StatusCode, string(body)))) | |
| 195 } | |
| 196 var issue Issue | |
| 197 err = json.Unmarshal(body, &issue) | |
| 198 if err != nil { | |
| 199 return nil, fmt.Errorf(errFmt, err) | |
| 200 } | |
|
jcgregorio
2014/05/08 15:11:57
if err := json.Unmarshal(body, &issue); err != nil
borenet
2014/05/09 15:40:11
Done.
| |
| 201 return &issue, nil | |
| 202 } | |
| 203 | |
| 204 // GetBugs retrieves all Issues with the given owner from the IssueTracker, | |
| 205 // returning an IssueList. | |
| 206 func (it *IssueTracker) GetBugs( | |
| 207 project string, owner string) (*IssueList, error) { | |
| 208 errFmt := "error retrieving issues: %s" | |
| 209 if !it.IsAuthenticated() { | |
| 210 return nil, fmt.Errorf(errFmt, | |
| 211 errors.New("user is not authenticated!")) | |
| 212 } | |
| 213 params := map[string]string{ | |
| 214 "owner": url.QueryEscape(owner), | |
| 215 "can": "open", | |
| 216 "maxResults": "9999", | |
| 217 } | |
| 218 requestURL := issueApiURL + project + "/issues?" | |
| 219 first := true | |
| 220 for k, v := range params { | |
| 221 if first { | |
| 222 first = false | |
| 223 } else { | |
| 224 requestURL += "&" | |
| 225 } | |
| 226 requestURL += k + "=" + v | |
| 227 } | |
| 228 resp, err := it.oauthTransport.Client().Get(requestURL) | |
| 229 if err != nil { | |
| 230 return nil, fmt.Errorf(errFmt, err) | |
| 231 } | |
| 232 defer resp.Body.Close() | |
| 233 body, _ := ioutil.ReadAll(resp.Body) | |
| 234 if resp.StatusCode != http.StatusOK { | |
| 235 return nil, fmt.Errorf(errFmt, errors.New(fmt.Sprintf( | |
| 236 "issue tracker returned code %d:%v", | |
| 237 resp.StatusCode, string(body)))) | |
| 238 } | |
| 239 | |
| 240 var bugList IssueList | |
| 241 err = json.Unmarshal(body, &bugList) | |
| 242 if err != nil { | |
| 243 return nil, fmt.Errorf(errFmt, err) | |
| 244 } | |
| 245 return &bugList, nil | |
| 246 } | |
| 247 | |
| 248 // SubmitIssueChanges creates a comment on the given Issue which modifies it | |
| 249 // according to the contents of the passed-in Issue struct. | |
| 250 func (it *IssueTracker) SubmitIssueChanges( | |
| 251 issue *Issue, comment string) error { | |
| 252 errFmt := "Error updating issue " + strconv.Itoa(issue.Id) + ": %s" | |
| 253 if !it.IsAuthenticated() { | |
| 254 return fmt.Errorf( | |
| 255 errFmt, errors.New("user is not authenticated!")) | |
| 256 } | |
| 257 oldIssue, err := it.GetBug(issue.Project, issue.Id) | |
| 258 if err != nil { | |
| 259 return fmt.Errorf(errFmt, err) | |
| 260 } | |
| 261 postData := struct { | |
| 262 Content string `json:"content"` | |
| 263 Updates struct { | |
| 264 Title *string `json:"summary"` | |
| 265 Labels []string `json:"labels"` | |
| 266 } `json:"updates"` | |
| 267 }{} | |
| 268 postData.Content = comment | |
|
jcgregorio
2014/05/08 15:11:57
}{
Content: comment,
}
borenet
2014/05/09 15:40:11
Done.
| |
| 269 if issue.Title != oldIssue.Title { | |
| 270 postData.Updates.Title = &issue.Title | |
| 271 } | |
| 272 // TODO(borenet): Add other issue attributes, eg. Owner. | |
| 273 labels := make(map[string]int) | |
| 274 for _, label := range issue.Labels { | |
| 275 labels[label] = labelAdded | |
| 276 } | |
| 277 for _, label := range oldIssue.Labels { | |
| 278 if _, ok := labels[label]; ok { | |
| 279 labels[label] = labelUnchanged | |
| 280 } else { | |
| 281 labels[label] = labelRemoved | |
| 282 } | |
| 283 } | |
| 284 labelChanges := make([]string, 0) | |
| 285 for labelName, present := range labels { | |
| 286 if present == labelRemoved { | |
| 287 labelChanges = append(labelChanges, "-"+labelName) | |
| 288 } else if present == labelAdded { | |
| 289 labelChanges = append(labelChanges, labelName) | |
| 290 } | |
| 291 } | |
| 292 if len(labelChanges) > 0 { | |
| 293 postData.Updates.Labels = labelChanges | |
| 294 } | |
| 295 | |
| 296 postBytes, err := json.Marshal(&postData) | |
| 297 if err != nil { | |
| 298 return fmt.Errorf(errFmt, err) | |
| 299 } | |
| 300 requestURL := issueApiURL + issue.Project + "/issues/" + | |
| 301 strconv.Itoa(issue.Id) + "/comments" | |
| 302 resp, err := it.oauthTransport.Client().Post( | |
| 303 requestURL, "application/json", bytes.NewReader(postBytes)) | |
| 304 if err != nil { | |
| 305 return fmt.Errorf(errFmt, err) | |
| 306 } | |
| 307 defer resp.Body.Close() | |
| 308 body, _ := ioutil.ReadAll(resp.Body) | |
| 309 if resp.StatusCode != http.StatusOK { | |
| 310 return fmt.Errorf(errFmt, errors.New(fmt.Sprintf( | |
| 311 "Issue tracker returned code %d:%v", | |
| 312 resp.StatusCode, string(body)))) | |
| 313 } | |
| 314 return nil | |
| 315 } | |
| OLD | NEW |