Home | History | Annotate | Download | only in issue_tracker
      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