blob: d981d254d3eea1c8e9e093e9d233ab9d3352de6e [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package monorail
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
"github.com/pkg/errors"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/luciauth"
"go.skia.org/infra/go/util"
compute "google.golang.org/api/compute/v1"
)
const (
// monorailBaseURLTemplate is the template for building the Monorail url.
monorailBaseURLTemplate = "https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects/%s/issues"
// monorailIssueReferenceTemplate is the template for the user-facing Monorail instance url.
// For testing, use the staging server at https://bugs-staging.fuchsia.dev.
monorailIssueReferenceTemplate = "https://bugs.fuchsia.dev/p/fuchsia/issues/detail?id=%d"
)
type IssueTracker struct {
client *http.Client
project string
url string
}
// IssueRef is a link from a Monorail issue to another Monorail issue, possibly in a different
// project.
type IssueRef struct {
IssueID int64 `json:"issueId"`
ProjectID string `json:"projectId"`
}
// IssuePerson is an entity representing a person in Monorail. It is used to describe reporters of
// issues, authors of comments, etc.
type IssuePerson struct {
EMailBouncing bool `json:"email_bouncing"`
HTMLLink string `json:"htmlLink"`
Name string `json:"name"`
}
type Status string
type IssueRequest struct {
CC []IssuePerson `json:"cc"`
Components []string `json:"components,omitempty"`
Description string `json:"description"`
Labels []string `json:"labels"`
Owner IssuePerson `json:"owner"`
Status string `json:"status"`
Summary string `json:"summary"`
}
type NewIssueResponse struct {
Author IssuePerson `json:"author"`
Components []string `json:"components"`
ID int64 `json:"id"`
Labels []string `json:"labels"`
Published string `json:"published"`
Status string `json:"status"`
Summary string `json:"summary"`
Title string `json:"title"`
}
// Available "can" values in the Monorail api. Reference:
// https://chromium.googlesource.com/infra/infra/+/main/appengine/monorail/doc/api.md
type CannedQuery int
const (
All CannedQuery = iota
New
Open
Owned
Starred
ToVerify
)
func (s CannedQuery) String() string {
return [...]string{"all", "new", "open", "owned", "starred", "to_verify"}[s]
}
type IssueQuery struct {
Can CannedQuery
Labels []string
SearchString string
}
type NewCommentResponse struct {
Content string `json:"content"`
ID int64 `json:"id"`
}
type UTCTime time.Time
// UnmarshalJSON parses time in the format used by Monorail JSON API.
func (t *UTCTime) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
p, err := time.Parse("2006-01-02T15:04:05", s)
if err != nil {
return err
}
*t = UTCTime(p)
return nil
}
func (t UTCTime) After(u UTCTime) bool {
v := time.Time(t)
w := time.Time(u)
return v.After(w)
}
// Issue is a Monorail entity representing an issue / bug.
type Issue struct {
Author *IssuePerson `json:"author"`
BlockedOn []*IssueRef `json:"blockedOn"`
Blocking []*IssueRef `json:"blocking"`
CC []*IssuePerson `json:"cc"`
CanComment bool `json:"canComment"`
CanEdit bool `json:"canEdit"`
Closed UTCTime `json:"closed"`
ComponentModified UTCTime `json:"component_modified"`
Components []string `json:"components"`
ID int64 `json:"id"`
Labels []string `json:"labels"`
Owner *IssuePerson `json:"owner"`
OwnerModified UTCTime `json:"owner_modified"`
ProjectID string `json:"projectId"`
Published UTCTime `json:"published"`
Starred bool `json:"starred"`
Stars int64 `json:"stars"`
State string `json:"state"`
Status Status `json:"status"`
StatusModified UTCTime `json:"status_modified"`
Summary string `json:"summary"`
Updated UTCTime `json:"updated"`
}
type IssueResponse struct {
Items []Issue `json:"items"`
TotalResults int64 `json:"totalResults"`
}
// IssueComment is an entity representing a comment in Monorail.
type IssueComment struct {
Author *IssuePerson `json:"author"`
CanDelete bool `json:"canDelete"`
Content string `json:"content"`
DeletedBy *IssuePerson `json:"deletedBy"`
ID int64 `json:"id"`
Published UTCTime `json:"published"`
}
type CommentUpdates struct {
Cc []string `json:"cc,omitempty"`
Components []string `json:"components,omitempty"`
Labels []string `json:"labels,omitempty"`
Owner string `json:"owner,omitempty"`
Status string `json:"status,omitempty"`
}
type CommentRequest struct {
Content string `json:"content"`
Updates CommentUpdates `json:"updates,omitempty"`
}
type CommentResponse struct {
Items []IssueComment `json:"items"`
}
type OptionalQueryParams map[string]string
// NewIssueTracker creates and returns an IssueTracker instance.
func NewIssueTracker(client *http.Client, project string) *IssueTracker {
return &IssueTracker{
client: client,
project: project,
url: fmt.Sprintf(monorailBaseURLTemplate, project),
}
}
func NewIssueTrackerFromLUCIContext(project string) (*IssueTracker, error) {
ts, err := luciauth.NewLUCIContextTokenSource(compute.CloudPlatformScope)
if err != nil {
return nil, err
}
mrClient := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
return NewIssueTracker(mrClient, project), nil
}
// FromQuery returns Monorail issues matching a given search string.
func (m *IssueTracker) FromQuery(issueQuery IssueQuery, optionalQueryParams OptionalQueryParams) ([]Issue, error) {
queryLink := buildQueryLink(m.url, issueQuery, optionalQueryParams)
return getIssues(m.client, queryLink)
}
// IssueComments returns comments on a Monorail issue.
func (m *IssueTracker) IssueComments(id int64) ([]IssueComment, error) {
u := fmt.Sprintf("%s/%d/comments?maxResults=10000", m.url, id)
return getComments(m.client, u)
}
// AddComment adds a comment to the issue with the given id.
func (m *IssueTracker) AddComment(id int64, comment CommentRequest) error {
u := fmt.Sprintf("%s/%d/comments?sendEmail=true", m.url, id)
if err := post(m.client, u, comment, logSuccessfulCommentPost); err != nil {
return errors.Wrap(err, "failed to add comment to Monorail issue")
}
return nil
}
// buildQueryLink combines default and optional query parameters into a usable url
// for Monorail's RESTful API.
func buildQueryLink(baseURL string, issueQuery IssueQuery, optionalQueryParams OptionalQueryParams) string {
query := url.Values{}
// add required params
query.Add("can", fmt.Sprint(issueQuery.Can))
for _, label := range issueQuery.Labels {
query.Add("label", label)
}
// Monorail defaults to only returning the last 100 issues, but allows up to 1000 issues in a single request.
query.Add("maxResults", "1000")
query.Add("q", issueQuery.SearchString)
query.Add("fields", "items/id,items/summary,items/stars,items/starred,items/status,items/state,items/labels,items/author,items/owner,items/cc,items/updated,items/published,items/closed,items/blockedOn,items/blocking,items/projectId,items/canComment,items/canEdit,items/components,items/component_modified,items/owner_modified,items/status_modified")
// add optional params
for k, v := range optionalQueryParams {
query.Add(k, v)
}
return fmt.Sprintf("%s?%s", baseURL, query.Encode())
}
func getComments(client *http.Client, u string) ([]IssueComment, error) {
resp, err := client.Get(u)
if err != nil || resp == nil {
return nil, fmt.Errorf("Failed to retrieve Monorail response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected Monorail response. Expected %d, received %d", http.StatusOK, resp.StatusCode)
}
defer util.Close(resp.Body)
commentResponse := &CommentResponse{
Items: []IssueComment{},
}
if err := json.NewDecoder(resp.Body).Decode(&commentResponse); err != nil {
return nil, err
}
return commentResponse.Items, nil
}
// AddIssue creates an issue with the passed in params.
func (m *IssueTracker) AddIssue(issue IssueRequest) (NewIssueResponse, error) {
req := struct {
IssueRequest
Project string `json:"projectId"`
}{
IssueRequest: issue,
Project: m.project,
}
newIssueResponse := NewIssueResponse{}
callback := func(resp []byte) error {
if err := json.Unmarshal(resp, &newIssueResponse); err != nil {
return errors.Wrap(err, "unable to unmarshal new Monorail issue response")
}
issueURL := fmt.Sprintf(monorailIssueReferenceTemplate, newIssueResponse.ID)
log.Printf("Issue filed!\nSummary:\n%s\nReference: %s\n", newIssueResponse.Summary, issueURL)
return nil
}
// since this request modifies a resource, send an email.
err := post(m.client, m.url+"?sendEmail=true", req, callback)
return newIssueResponse, err
}
func getIssues(client *http.Client, u string) ([]Issue, error) {
resp, err := client.Get(u)
if err != nil || resp == nil {
return nil, fmt.Errorf("Failed to retrieve Monorail response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected Monorail response. Expected %d, received %d", http.StatusOK, resp.StatusCode)
}
defer util.Close(resp.Body)
issueResponse := &IssueResponse{}
if err := json.NewDecoder(resp.Body).Decode(&issueResponse); err != nil {
return nil, err
}
return issueResponse.Items, nil
}
func logSuccessfulCommentPost(resp []byte) error {
newCommentResponse := NewCommentResponse{}
if err := json.Unmarshal(resp, &newCommentResponse); err != nil {
return errors.Wrap(err, "unable to unmarshal new Monorail comment response")
}
log.Printf("Comment added! \nSummary: \n%s\n", newCommentResponse.Content)
return nil
}
func post(client *http.Client, dst string, request any, callback func(r []byte) error) error {
b := &bytes.Buffer{}
e := json.NewEncoder(b)
if err := e.Encode(request); err != nil {
return errors.Wrap(err, "failed to encode json for request")
}
resp, err := client.Post(dst, "application/json", b)
if err != nil || resp == nil {
return errors.Wrap(err, "failed to retrieve Monorail response")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected Monorail response. Expected %d, received %d", http.StatusOK, resp.StatusCode)
}
defer util.Close(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "failed to read response body")
}
if err := callback(body); err != nil {
return errors.Wrap(err, "failed to log Monorail post request")
}
return nil
}