• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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*/
15package issue_tracker
16
17import (
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.
30var BugPriorities = []string{"Critical", "High", "Medium", "Low", "Never"}
31
32var apiScope = []string{
33	"https://www.googleapis.com/auth/projecthosting",
34	"https://www.googleapis.com/auth/userinfo.email",
35}
36
37const issueApiURL = "https://www.googleapis.com/projecthosting/v2/projects/"
38const issueURL = "https://code.google.com/p/skia/issues/detail?id="
39const personApiURL = "https://www.googleapis.com/userinfo/v2/me"
40
41// Enum for determining whether a label has been added, removed, or is
42// unchanged.
43const (
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.
51func 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.
80type 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.
88func (i Issue) URL() string {
89	return issueURL + strconv.Itoa(i.Id)
90}
91
92// IssueList represents a list of issues from the IssueTracker.
93type 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.
100type 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.
107func 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.
122func (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.
130func (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.
138func (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.
150func (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.
175func (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.
200func (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.
240func (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