blob: 2138f6b9de757848c3b7ccaa7eee91b67143845d [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"`
Commit commitInfo `json:"commit"`
}
type commitInfo struct {
Committer person `json:"committer"`
}
type person struct {
Name string `json:"name"`
Email string `json:"email"`
DateInRFC3339 string `json:"date"`
}
// https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request
type commitsOnPullRequestResponse []commit
// GetPatchset implements the code_review.Client interface.
func (c *CRSImpl) GetPatchset(ctx context.Context, clID, psID string, psOrder int) (code_review.Patchset, error) {
if _, err := strconv.ParseInt(clID, 10, 64); err != nil {
return code_review.Patchset{}, skerr.Fmt("invalid Changelist ID")
}
// At the moment, paging returns 30 at a time and the API docs say that it stops after
// 250 commits. Just to be safe, we should bail out of page is more than 20 (~600 patchsets) in
// an effort to not hang on some unanticipated response. Normally we break when the API
// gives us 0 responses for a page, indicating we have all the patchsets.
order := 0
for page := 1; page < 20; page++ {
// Respect the rate limit.
if err := c.rl.Wait(ctx); err != nil {
return code_review.Patchset{}, skerr.Wrap(err)
}
u := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%s/commits?page=%d", c.repo, clID, page)
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 code_review.Patchset{}, code_review.ErrNotFound
}
var cprr commitsOnPullRequestResponse
err = json.NewDecoder(resp.Body).Decode(&cprr)
if err != nil {
util.Close(resp.Body)
return code_review.Patchset{}, skerr.Wrapf(err, "received invalid JSON from GitHub: %s", u)
}
util.Close(resp.Body)
// Assume GitHub returns these in ascending order
for _, ps := range cprr {
order++
if psOrder == order || psID == ps.Hash {
ts, err := time.Parse(time.RFC3339, ps.Commit.Committer.DateInRFC3339)
if err != nil {
return code_review.Patchset{}, skerr.Wrapf(err, "parsing date on PR %s commit %s: %#v", clID, psID, ps.Commit)
}
return code_review.Patchset{
SystemID: ps.Hash,
ChangelistID: clID,
Order: order,
GitHash: ps.Hash,
Created: ts,
}, nil
}
}
if len(cprr) == 0 {
break
}
}
return code_review.Patchset{}, code_review.ErrNotFound
}
// 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 {
sklog.Infof("Commenting on GitHub CL (PR) %s with message %q", clID, message)
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)