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