Golang程序  |  304行  |  9.09 KB

// 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
}