blob: 7e01e5fd09c85b1a570b3b8915852e2bdbfe4724 [file] [log] [blame]
// Package github_crs provides a client for Gold's interaction with
// the GitHub code review system.
package github_crs
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/vcsinfo"
"go.skia.org/infra/golden/go/code_review"
"golang.org/x/time/rate"
)
const (
// Authenticated clients can do up to 5000 queries per hour. These limits
// are conservative based on that.
maxQPS = rate.Limit(1)
maxBurst = 100
)
type CRSImpl struct {
client *http.Client
rl *rate.Limiter
repo string
}
// New returns a new instance of CRSImpl, ready to target a single
// GitHub repo. repo should be the user/repo, e.g. "google/skia",
// "flutter/flutter", etc.
func New(client *http.Client, repo string) *CRSImpl {
return &CRSImpl{
client: client,
rl: rate.NewLimiter(maxQPS, maxBurst),
repo: repo,
}
}
type user struct {
UserName string `json:"login"`
}
// See https://developer.github.com/v3/pulls/#get-a-single-pull-request
type pullRequestResponse struct {
Title string `json:"title"`
User user `json:"user"`
State string `json:"state"`
Updated string `json:"updated_at"` // e.g. "2011-01-26T19:01:12Z"
Merged string `json:"merged_at"`
}
// GetChangeList implements the code_review.Client interface.
func (c *CRSImpl) GetChangeList(ctx context.Context, id string) (code_review.ChangeList, error) {
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return code_review.ChangeList{}, skerr.Fmt("invalid ChangeList ID")
}
// Respect the rate limit.
if err := c.rl.Wait(ctx); err != nil {
return code_review.ChangeList{}, skerr.Wrap(err)
}
u := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%s", c.repo, id)
resp, err := httputils.GetWithContext(ctx, c.client, u)
if err != nil {
sklog.Errorf("Error getting ChangeList from %s: %s", u, err)
// Assume an error here is the ChangeList is not found
return code_review.ChangeList{}, code_review.ErrNotFound
}
defer util.Close(resp.Body)
var prr pullRequestResponse
err = json.NewDecoder(resp.Body).Decode(&prr)
if err != nil {
return code_review.ChangeList{}, skerr.Wrapf(err, "received invalid JSON from GitHub: %s", u)
}
state := code_review.Open
if prr.State == "closed" {
if prr.Merged != "" {
state = code_review.Landed
} else {
state = code_review.Abandoned
}
}
updated, err := time.Parse(time.RFC3339, prr.Updated)
if err != nil {
return code_review.ChangeList{}, skerr.Wrapf(err, "invalid time %q", prr.Updated)
}
return code_review.ChangeList{
SystemID: id,
Owner: prr.User.UserName,
Subject: prr.Title,
Status: state,
Updated: updated,
}, nil
}
type commit struct {
Hash string `json:"sha"`
}
// https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request
type commitsOnPullRequestResponse []commit
// GetPatchSets implements the code_review.Client interface.
func (c *CRSImpl) GetPatchSets(ctx context.Context, clID string) ([]code_review.PatchSet, error) {
if _, err := strconv.ParseInt(clID, 10, 64); err != nil {
return nil, skerr.Fmt("invalid ChangeList ID")
}
// Respect the rate limit.
if err := c.rl.Wait(ctx); err != nil {
return nil, skerr.Wrap(err)
}
u := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%s/commits", c.repo, clID)
resp, err := httputils.GetWithContext(ctx, c.client, u)
if err != nil {
sklog.Errorf("Error getting commits on PR %s with url %s: %s", clID, u, err)
// Assume an error here is the ChangeList is not found
return nil, code_review.ErrNotFound
}
defer util.Close(resp.Body)
var cprr commitsOnPullRequestResponse
err = json.NewDecoder(resp.Body).Decode(&cprr)
if err != nil {
return nil, skerr.Wrapf(err, "received invalid JSON from GitHub: %s", u)
}
// Assume GitHub returns these in ascending order
var xps []code_review.PatchSet
for i, c := range cprr {
xps = append(xps, code_review.PatchSet{
SystemID: c.Hash,
ChangeListID: clID,
Order: i + 1,
GitHash: c.Hash,
})
}
return xps, nil
}
// GetChangeListIDForCommit implements the code_review.Client interface.
func (c *CRSImpl) GetChangeListIDForCommit(ctx context.Context, commit *vcsinfo.LongCommit) (string, error) {
if commit == nil {
return "", skerr.Fmt("commit cannot be nil")
}
id, err := extractPRFromTitle(commit.Subject)
if err != nil {
sklog.Debugf("Could not find github issue: %s", err)
return "", code_review.ErrNotFound
}
return id, nil
}
// We assume a PR has the pull request number in the Subject/Title, at the end.
// e.g. "Turn off docs upload temporarily (#44365) (#44413)" refers to PR 44413
var prSuffix = regexp.MustCompile(`.+\(#(?P<id>\d+)\)\s*$`)
// extractPRFromTitle returns the pull request id extracted from the title
// of a landed PR, or an error if it cannot.
func extractPRFromTitle(t string) (string, error) {
if match := prSuffix.FindStringSubmatch(t); match != nil {
// match[0] is the whole string, match[1] is the first group
return match[1], nil
}
return "", skerr.Fmt("Could not find PR in Subject %q", t)
}
// CommentOn implements the code_review.Client interface.
// https://developer.github.com/v3/issues/comments/#create-a-comment
func (c *CRSImpl) CommentOn(ctx context.Context, clID, message string) error {
if _, err := strconv.ParseInt(clID, 10, 64); err != nil {
return skerr.Fmt("invalid ChangeList ID")
}
// Respect the rate limit.
if err := c.rl.Wait(ctx); err != nil {
return skerr.Wrap(err)
}
u := fmt.Sprintf("https://api.github.com/repos/%s/issues/%s/comments", c.repo, clID)
j := fmt.Sprintf(`{"body":%q}`, message)
_, err := httputils.PostWithContext(ctx, c.client, u, "application/json", strings.NewReader(j))
return skerr.Wrap(err)
}
// System implements the code_review.Client interface.
func (c *CRSImpl) System() string {
return "github"
}
// Make sure CRSImpl fulfills the code_review.Client interface.
var _ code_review.Client = (*CRSImpl)(nil)