blob: 8009406a9a954b0cc73b9d7ddf90e904ef35949e [file] [log] [blame]
// Copyright 2020 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// monorail.go provides an interface to the Monorail API.
package monorail
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/discovery/v1"
"google.golang.org/api/option"
)
// timeout is the amount of time we give our context before we call it a timeout error.
const timeout time.Duration = 60 * time.Second
// discoveryURL is the starting point for using the discovery API.
// Found on https://chromium.googlesource.com/infra/infra/+/master/appengine/monorail/doc/example.py
const discoveryURL string = "https://bugs.chromium.org/_ah/api/discovery/v1/"
// requestParam contains the name of a parameter used in some URL querystring.
type requestParam string
// Below are all of the requestParams accepted by the monorail.issues.list API.
const (
ProjectID requestParam = "projectId"
AdditionalProject requestParam = "additionalProject"
Can requestParam = "can"
Label requestParam = "label"
MaxResults requestParam = "maxResults"
Owner requestParam = "owner"
PublishedMax requestParam = "publishedMax"
PublishedMin requestParam = "publishedMin"
Q requestParam = "q"
Sort requestParam = "sort"
StartIndex requestParam = "startIndex"
Status requestParam = "status"
UpdatedMax requestParam = "updatedMax"
UpdatedMin requestParam = "updatedMin"
)
// UnwrittenFwTestReqParams contains the canonical parameters to query Monorail for unwritten FW tests.
var UnwrittenFwTestReqParams = map[requestParam]string{
Q: "Component:OS>Firmware Component:Test>ChromeOS", // Space-separated means AND
Label: "Test-Missing,Test-Flaky,Test-Escape", // Comma-separated means OR
Can: "open"} // Canned query filters down to open issues
// User contains all the data about an author/cc/owner returned by the monorail.issues.list API.
type User struct {
Kind string
HTMLLink string
EmailBouncing bool // Actual field in API response: email_bouncing
name string
}
// Issue contains all the data about an issue returned by the monorail.issues.list API.
type Issue struct {
Status string
Updated string
OwnerModified string // Actual field in API response: owner_modified
CanEdit bool
ComponentModified string // Actual field in API response: component_modified
Author User
CC []User
ProjectID string
Labels []string
Kind string
CanComment bool
State string
StatusModified string // Actual field in API response: status_modified
Title string
Stars int
Published string
Owner User
Components []string
Starred bool
Summary string
ID int
}
// IssuesListResponse contains all the data returned by the monorail.issues.list API.
type IssuesListResponse struct {
Items []Issue
Kind string
TotalResults int
}
// credentialsFile finds the file containing the service account's credentials.
func credentialsFile() (string, error) {
const credentialsBasename = "fw_monorail_credentials.json"
const credentialsDir = "/usr/local/bin/"
const credentialsURL = "https://gredelston.users.x20web.corp.google.com/fw_monorail_credentials.json"
credentialsFile := credentialsDir + credentialsBasename
if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
log.Printf("Could not find credentials file. Please download from %s and move to %s.", credentialsURL, credentialsDir)
return "", fmt.Errorf("file not found %s", credentialsFile)
}
return credentialsFile, nil
}
// oauthConfig creates a JWT oAuth config containing the service account's credentials.
func oauthConfig() (*jwt.Config, error) {
f, err := credentialsFile()
if err != nil {
return nil, fmt.Errorf("finding credentials file: %v", err)
}
j, err := ioutil.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("reading credentials file %s: %v", f, err)
}
cfg, err := google.JWTConfigFromJSON(j, "https://www.googleapis.com/auth/userinfo.email")
if err != nil {
return nil, fmt.Errorf("generating JWT config: %v", err)
}
return cfg, nil
}
// querystring constructs the Query portion of a URL based on key-value pairs.
// Special characters are escaped. The "?" preceding the querystring is not included.
// Example: {"foo": "bar", "baz": "4 5"} --> "foo=bar&baz=4%205"
func querystring(params map[requestParam]string) string {
var paramQueries []string
for k, v := range params {
paramQueries = append(paramQueries, fmt.Sprintf("%s=%s", k, url.QueryEscape(v)))
}
return strings.Join(paramQueries, "&")
}
// issuesListURL constructs a GET URL to query the monorail.issues.list API.
func issuesListURL(ctx context.Context, httpClient *http.Client, params map[requestParam]string) (string, error) {
svc, err := discovery.NewService(ctx, option.WithHTTPClient(httpClient), option.WithEndpoint(discoveryURL))
if err != nil {
return "", fmt.Errorf("connecting to Discovery: %v", err)
}
rest, err := svc.Apis.GetRest("monorail", "v1").Do()
if err != nil {
return "", fmt.Errorf("getting Monorail REST description: %v", err)
}
method := rest.Resources["issues"].Methods["list"]
u := strings.Join([]string{rest.RootUrl, rest.ServicePath, method.Path}, "")
u = strings.ReplaceAll(u, "{projectId}", "chromium")
if len(params) > 0 {
u = fmt.Sprintf("%s?%s", u, querystring(params))
}
return u, nil
}
// IssuesList polls the monorail.issues.list API for all issues matching certain parameters.
func IssuesList(params map[requestParam]string) ([]Issue, error) {
log.Print("Going to query Monorail for issues matching params:")
if len(params) > 0 {
for k, v := range params {
log.Printf("\t%s: %s", k, v)
}
} else {
log.Print("\nnil")
}
// Set a timeout for all our requests
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Create an authenticated HTTP client
cfg, err := oauthConfig()
if err != nil {
return nil, fmt.Errorf("creating OAuth config: %v", err)
}
httpClient := oauth2.NewClient(ctx, cfg.TokenSource(ctx))
// Send a request to the API
u, err := issuesListURL(ctx, httpClient, params)
if err != nil {
return nil, fmt.Errorf("constructing API URL: %v", err)
}
log.Printf("Sending GET request to %s\n", u)
hResponse, err := httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("sending API call to \"%s\": %v", u, err)
}
log.Print("Response received.")
// Parse response
// Note: A few fields will not successfully unmarshal from the blob.
// json.Unmarshal attempts to match field names from the response blob to attributes of IssuesListResponse (and its children) in a case-insensitive way.
// Four fields of the response contain underscores in their names.
// Golint forbids us to use underscores in Go names.
// Therefore, for those four fields, we are unable to create struct fields which will be matched by json.Unmarshal.
// Luckily, we aren't using any of those fields (for now), so we can just let them not get populated.
blob, err := ioutil.ReadAll(hResponse.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %v", err)
}
var issuesListResponse IssuesListResponse
if err = json.Unmarshal(blob, &issuesListResponse); err != nil {
return nil, fmt.Errorf("unmarhsalling API response body: %v", err)
}
return issuesListResponse.Items, nil
}