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