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