// Copyright (c) 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* Utilities for interacting with the GoogleCode issue tracker. Example usage: issueTracker := issue_tracker.MakeIssueTraker(myOAuthConfigFile) authURL := issueTracker.MakeAuthRequestURL() // Visit the authURL to obtain an authorization code. issueTracker.UpgradeCode(code) // Now issueTracker can be used to retrieve and edit issues. */ package issue_tracker import ( "bytes" "code.google.com/p/goauth2/oauth" "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" "strconv" "strings" ) // BugPriorities are the possible values for "Priority-*" labels for issues. var BugPriorities = []string{"Critical", "High", "Medium", "Low", "Never"} var apiScope = []string{ "https://www.googleapis.com/auth/projecthosting", "https://www.googleapis.com/auth/userinfo.email", } const issueApiURL = "https://www.googleapis.com/projecthosting/v2/projects/" const issueURL = "https://code.google.com/p/skia/issues/detail?id=" const personApiURL = "https://www.googleapis.com/userinfo/v2/me" // Enum for determining whether a label has been added, removed, or is // unchanged. const ( labelAdded = iota labelRemoved labelUnchanged ) // loadOAuthConfig reads the OAuth given config file path and returns an // appropriate oauth.Config. func loadOAuthConfig(oauthConfigFile string) (*oauth.Config, error) { errFmt := "failed to read OAuth config file: %s" fileContents, err := ioutil.ReadFile(oauthConfigFile) if err != nil { return nil, fmt.Errorf(errFmt, err) } var decodedJson map[string]struct { AuthURL string `json:"auth_uri"` ClientId string `json:"client_id"` ClientSecret string `json:"client_secret"` TokenURL string `json:"token_uri"` } if err := json.Unmarshal(fileContents, &decodedJson); err != nil { return nil, fmt.Errorf(errFmt, err) } config, ok := decodedJson["web"] if !ok { return nil, fmt.Errorf(errFmt, err) } return &oauth.Config{ ClientId: config.ClientId, ClientSecret: config.ClientSecret, Scope: strings.Join(apiScope, " "), AuthURL: config.AuthURL, TokenURL: config.TokenURL, }, nil } // Issue contains information about an issue. type Issue struct { Id int `json:"id"` Project string `json:"projectId"` Title string `json:"title"` Labels []string `json:"labels"` } // URL returns the URL of a given issue. func (i Issue) URL() string { return issueURL + strconv.Itoa(i.Id) } // IssueList represents a list of issues from the IssueTracker. type IssueList struct { TotalResults int `json:"totalResults"` Items []*Issue `json:"items"` } // IssueTracker is the primary point of contact with the issue tracker, // providing methods for authenticating to and interacting with it. type IssueTracker struct { OAuthConfig *oauth.Config OAuthTransport *oauth.Transport } // MakeIssueTracker creates and returns an IssueTracker with authentication // configuration from the given authConfigFile. func MakeIssueTracker(authConfigFile string, redirectURL string) (*IssueTracker, error) { oauthConfig, err := loadOAuthConfig(authConfigFile) if err != nil { return nil, fmt.Errorf( "failed to create IssueTracker: %s", err) } oauthConfig.RedirectURL = redirectURL return &IssueTracker{ OAuthConfig: oauthConfig, OAuthTransport: &oauth.Transport{Config: oauthConfig}, }, nil } // MakeAuthRequestURL returns an authentication request URL which can be used // to obtain an authorization code via user sign-in. func (it IssueTracker) MakeAuthRequestURL() string { // NOTE: Need to add XSRF protection if we ever want to run this on a public // server. return it.OAuthConfig.AuthCodeURL(it.OAuthConfig.RedirectURL) } // IsAuthenticated determines whether the IssueTracker has sufficient // permissions to retrieve and edit Issues. func (it IssueTracker) IsAuthenticated() bool { return it.OAuthTransport.Token != nil } // UpgradeCode exchanges the single-use authorization code, obtained by // following the URL obtained from IssueTracker.MakeAuthRequestURL, for a // multi-use, session token. This is required before IssueTracker can retrieve // and edit issues. func (it *IssueTracker) UpgradeCode(code string) error { token, err := it.OAuthTransport.Exchange(code) if err == nil { it.OAuthTransport.Token = token return nil } else { return fmt.Errorf( "failed to exchange single-user auth code: %s", err) } } // GetLoggedInUser retrieves the email address of the authenticated user. func (it IssueTracker) GetLoggedInUser() (string, error) { errFmt := "error retrieving user email: %s" if !it.IsAuthenticated() { return "", fmt.Errorf(errFmt, "User is not authenticated!") } resp, err := it.OAuthTransport.Client().Get(personApiURL) if err != nil { return "", fmt.Errorf(errFmt, err) } defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf(errFmt, fmt.Sprintf( "user data API returned code %d: %v", resp.StatusCode, string(body))) } userInfo := struct { Email string `json:"email"` }{} if err := json.Unmarshal(body, &userInfo); err != nil { return "", fmt.Errorf(errFmt, err) } return userInfo.Email, nil } // GetBug retrieves the Issue with the given ID from the IssueTracker. func (it IssueTracker) GetBug(project string, id int) (*Issue, error) { errFmt := fmt.Sprintf("error retrieving issue %d: %s", id, "%s") if !it.IsAuthenticated() { return nil, fmt.Errorf(errFmt, "user is not authenticated!") } requestURL := issueApiURL + project + "/issues/" + strconv.Itoa(id) resp, err := it.OAuthTransport.Client().Get(requestURL) if err != nil { return nil, fmt.Errorf(errFmt, err) } defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf(errFmt, fmt.Sprintf( "issue tracker returned code %d:%v", resp.StatusCode, string(body))) } var issue Issue if err := json.Unmarshal(body, &issue); err != nil { return nil, fmt.Errorf(errFmt, err) } return &issue, nil } // GetBugs retrieves all Issues with the given owner from the IssueTracker, // returning an IssueList. func (it IssueTracker) GetBugs(project string, owner string) (*IssueList, error) { errFmt := "error retrieving issues: %s" if !it.IsAuthenticated() { return nil, fmt.Errorf(errFmt, "user is not authenticated!") } params := map[string]string{ "owner": url.QueryEscape(owner), "can": "open", "maxResults": "9999", } requestURL := issueApiURL + project + "/issues?" first := true for k, v := range params { if first { first = false } else { requestURL += "&" } requestURL += k + "=" + v } resp, err := it.OAuthTransport.Client().Get(requestURL) if err != nil { return nil, fmt.Errorf(errFmt, err) } defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf(errFmt, fmt.Sprintf( "issue tracker returned code %d:%v", resp.StatusCode, string(body))) } var bugList IssueList if err := json.Unmarshal(body, &bugList); err != nil { return nil, fmt.Errorf(errFmt, err) } return &bugList, nil } // SubmitIssueChanges creates a comment on the given Issue which modifies it // according to the contents of the passed-in Issue struct. func (it IssueTracker) SubmitIssueChanges(issue *Issue, comment string) error { errFmt := "Error updating issue " + strconv.Itoa(issue.Id) + ": %s" if !it.IsAuthenticated() { return fmt.Errorf(errFmt, "user is not authenticated!") } oldIssue, err := it.GetBug(issue.Project, issue.Id) if err != nil { return fmt.Errorf(errFmt, err) } postData := struct { Content string `json:"content"` Updates struct { Title *string `json:"summary"` Labels []string `json:"labels"` } `json:"updates"` }{ Content: comment, } if issue.Title != oldIssue.Title { postData.Updates.Title = &issue.Title } // TODO(borenet): Add other issue attributes, eg. Owner. labels := make(map[string]int) for _, label := range issue.Labels { labels[label] = labelAdded } for _, label := range oldIssue.Labels { if _, ok := labels[label]; ok { labels[label] = labelUnchanged } else { labels[label] = labelRemoved } } labelChanges := make([]string, 0) for labelName, present := range labels { if present == labelRemoved { labelChanges = append(labelChanges, "-"+labelName) } else if present == labelAdded { labelChanges = append(labelChanges, labelName) } } if len(labelChanges) > 0 { postData.Updates.Labels = labelChanges } postBytes, err := json.Marshal(&postData) if err != nil { return fmt.Errorf(errFmt, err) } requestURL := issueApiURL + issue.Project + "/issues/" + strconv.Itoa(issue.Id) + "/comments" resp, err := it.OAuthTransport.Client().Post( requestURL, "application/json", bytes.NewReader(postBytes)) if err != nil { return fmt.Errorf(errFmt, err) } defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf(errFmt, fmt.Sprintf( "Issue tracker returned code %d:%v", resp.StatusCode, string(body))) } return nil }