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 public 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, string(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, string(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, string(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, string(body))) 301 } 302 return nil 303 } 304