blob: d29ac500e505d7dbef139c649ea30f5ade190d39 [file] [log] [blame]
// package github provides a library for interacting with Github via it's API:
// https://developer.github.com/v3/
//
// This library assumes that the http.Client provided in NewGitHub
// contains the appropriate authentication.
// One way to authenticate is to use a personal access token as described in
// https://developer.github.com/v3/auth/. Apps can retreive this from metadata.
// Other way to authenticate is to use client_id and client_secret as described
// in https://developer.github.com/v3/oauth_authorizations/. That can also be
// retreived by apps via metadata.
//
// We would rather use service accounts but Github only supports service
// accounts in Github apps:
// https://developer.github.com/apps/differences-between-apps/
package github
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/google/go-github/github"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/sklog"
)
const (
GITHUB_TOKEN_METADATA_KEY = "github_token"
GITHUB_TOKEN_FILENAME = "github_token"
GITHUB_TOKEN_SERVER_PATH = "/var/secrets/github-token"
SSH_KEY_FILENAME = "id_rsa"
SSH_KEY_SERVER_PATH = "/var/secrets/ssh-key"
MERGE_METHOD_SQUASH = "squash"
MERGE_METHOD_REBASE = "rebase"
MERGEABLE_STATE_DIRTY = "dirty" // Merge conflict.
MERGEABLE_STATE_CLEAN = "clean" // No conflicts.
MERGEABLE_STATE_UNKNOWN = "unknown" // Mergeablility was not checked yet.
MERGEABLE_STATE_UNSTABLE = "unstable" // Failing or pending commit status.
COMMIT_LABEL = "autoroller: commit"
DRYRUN_LABEL = "autoroller: dyrun"
CHECK_STATE_PENDING = "pending"
CHECK_STATE_SUCCESS = "success"
CHECK_STATE_ERROR = "error"
CHECK_STATE_FAILURE = "failure"
)
var (
CLOSED_STATE = "closed"
)
// GitHub is used for iteracting with the GitHub API.
type GitHub struct {
RepoOwner string
RepoName string
client *github.Client
httpClient *http.Client
ctx context.Context
}
// NewGitHub returns a new GitHub instance.
func NewGitHub(ctx context.Context, repoOwner, repoName string, httpClient *http.Client) (*GitHub, error) {
client := github.NewClient(httpClient)
return &GitHub{
RepoOwner: repoOwner,
RepoName: repoName,
client: client,
httpClient: httpClient,
ctx: ctx,
}, nil
}
// AddToKnownHosts adds github.com to .ssh/known_hosts. Without this,
// interactions with github do not work.
func AddToKnownHosts(ctx context.Context) {
// From https://serverfault.com/questions/132970/can-i-automatically-add-a-new-host-to-known-hosts
// Not throwing error below because github does not provide shell access
// and thus always returns an error.
_, err := exec.RunCwd(ctx, "", "ssh", "-T", "git@github.com", "-oStrictHostKeyChecking=no")
sklog.Info(err)
}
// See https://developer.github.com/v3/issues/comments/#create-a-comment
// for the API documentation.
func (g *GitHub) AddComment(pullRequestNum int, msg string) error {
comment := &github.IssueComment{
Body: &msg,
}
_, resp, err := g.client.Issues.CreateComment(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum, comment)
if err != nil {
return fmt.Errorf("Failed doing issues.createcomment: %s", err)
}
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("Unexpected status code %d from issues.createcomment.", resp.StatusCode)
}
return nil
}
// See https://developer.github.com/v3/users/#get-the-authenticated-user
// for the API documentation.
func (g *GitHub) GetAuthenticatedUser() (*github.User, error) {
user, resp, err := g.client.Users.Get(g.ctx, "")
if err != nil {
return nil, fmt.Errorf("Failed doing users.get: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected status code %d from users.get.", resp.StatusCode)
}
return user, nil
}
// See https://developer.github.com/v3/pulls/#get-a-single-pull-request
// for the API documentation.
func (g *GitHub) GetPullRequest(pullRequestNum int) (*github.PullRequest, error) {
pullRequest, resp, err := g.client.PullRequests.Get(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum)
if err != nil {
return nil, fmt.Errorf("Failed doing pullrequests.get: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected status code %d from pullrequests.get.", resp.StatusCode)
}
return pullRequest, nil
}
// See https://developer.github.com/v3/pulls/#create-a-pull-request
// for the API documentation.
func (g *GitHub) CreatePullRequest(title, baseBranch, headBranch, body string) (*github.PullRequest, error) {
newPullRequest := &github.NewPullRequest{
Title: &title,
Base: &baseBranch,
Head: &headBranch,
Body: &body,
}
pullRequest, resp, err := g.client.PullRequests.Create(g.ctx, g.RepoOwner, g.RepoName, newPullRequest)
if err != nil {
return nil, fmt.Errorf("Failed doing pullrequests.create: %s", err)
}
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("Unexpected status code %d from pullrequests.create.", resp.StatusCode)
}
return pullRequest, nil
}
// See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button
// for the API documentation.
func (g *GitHub) MergePullRequest(pullRequestNum int, msg, mergeMethod string) error {
options := &github.PullRequestOptions{
MergeMethod: mergeMethod,
}
_, resp, err := g.client.PullRequests.Merge(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum, msg, options)
if err != nil {
return fmt.Errorf("Failed doing pullrequests.merge: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected status code %d from pullrequests.merge.", resp.StatusCode)
}
return nil
}
// See https://developer.github.com/v3/pulls/#update-a-pull-request
// for the API documentation.
func (g *GitHub) ClosePullRequest(pullRequestNum int) (*github.PullRequest, error) {
editPullRequest := &github.PullRequest{
State: &CLOSED_STATE,
}
edittedPullRequest, resp, err := g.client.PullRequests.Edit(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum, editPullRequest)
if err != nil {
return nil, fmt.Errorf("Failed doing pullrequests.edit: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected status code %d from pullrequests.edit.", resp.StatusCode)
}
if edittedPullRequest.GetState() != CLOSED_STATE {
return nil, fmt.Errorf("Tried to close pull request %d but the state is %s", pullRequestNum, edittedPullRequest.GetState())
}
return edittedPullRequest, nil
}
func getLabelNames(labels []github.Label) []string {
labelNames := []string{}
for _, l := range labels {
labelNames = append(labelNames, l.GetName())
}
return labelNames
}
// See https://developer.github.com/v3/issues/#get-a-single-issue
// for the API documentation.
func (g *GitHub) GetLabels(pullRequestNum int) ([]string, error) {
pullRequest, resp, err := g.client.Issues.Get(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum)
if err != nil {
return nil, fmt.Errorf("Failed doing issues.get: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected status code %d from issues.get.", resp.StatusCode)
}
return getLabelNames(pullRequest.Labels), nil
}
// See https://developer.github.com/v3/issues/#edit-an-issue
// for the API documentation.
func (g *GitHub) AddLabel(pullRequestNum int, newLabel string) error {
// Get all existing labels on the PR.
labels, err := g.GetLabels(pullRequestNum)
if err != nil {
return fmt.Errorf("Error when getting labels for %d: %s", pullRequestNum, err)
}
// Add the new labels.
labels = append(labels, newLabel)
req := &github.IssueRequest{
Labels: &labels,
}
_, resp, err := g.client.Issues.Edit(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum, req)
if err != nil {
return fmt.Errorf("Failed doing issues.edit: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected status code %d from issues.edit.", resp.StatusCode)
}
return nil
}
// See https://developer.github.com/v3/issues/#edit-an-issue
// for the API documentation.
// Note: This adds the newLabel even if the oldLabel is not found.
func (g *GitHub) ReplaceLabel(pullRequestNum int, oldLabel, newLabel string) error {
// Get all existing labels on the PR.
existingLabels, err := g.GetLabels(pullRequestNum)
if err != nil {
return fmt.Errorf("Error when getting labels for %d: %s", pullRequestNum, err)
}
// Remove the specified label.
newLabels := []string{}
for _, l := range existingLabels {
if l != oldLabel {
newLabels = append(newLabels, l)
}
}
// Add the new label.
newLabels = append(newLabels, newLabel)
req := &github.IssueRequest{
Labels: &newLabels,
}
_, resp, err := g.client.Issues.Edit(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum, req)
if err != nil {
return fmt.Errorf("Failed doing issues.edit: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected status code %d from issues.edit.", resp.StatusCode)
}
return nil
}
// See https://developer.github.com/v3/repos/commits/#get-a-single-commit
// for the API documentation.
func (g *GitHub) GetChecks(ref string) ([]github.RepoStatus, error) {
combinedStatus, resp, err := g.client.Repositories.GetCombinedStatus(g.ctx, g.RepoOwner, g.RepoName, ref, nil)
if err != nil {
return nil, fmt.Errorf("Failed doing repos.get: %s", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Unexpected status code %d from repos.get.", resp.StatusCode)
}
return combinedStatus.Statuses, nil
}
// See https://developer.github.com/v3/issues/#get-a-single-issue
// for the API documentation.
func (g *GitHub) GetDescription(pullRequestNum int) (string, error) {
issue, resp, err := g.client.Issues.Get(g.ctx, g.RepoOwner, g.RepoName, pullRequestNum)
if err != nil {
return "", fmt.Errorf("Failed doing issues.get: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Unexpected status code %d from issues.get.", resp.StatusCode)
}
return issue.GetBody(), nil
}
func (g *GitHub) ReadRawFile(branch, filePath string) (string, error) {
githubContentURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", g.RepoOwner, g.RepoName, branch, filePath)
resp, err := g.httpClient.Get(githubContentURL)
if err != nil {
return "", fmt.Errorf("Error when hitting %s: %s", githubContentURL, err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Unexpected status code %d from %s", resp.StatusCode, githubContentURL)
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Could not read from %s: %s", githubContentURL, err)
}
return string(bodyBytes), nil
}
func (g *GitHub) GetFullHistoryUrl(userEmail string) string {
user := strings.Split(userEmail, "@")[0]
return fmt.Sprintf("https://github.com/%s/%s/pulls/%s", g.RepoOwner, g.RepoName, user)
}
func (g *GitHub) GetIssueUrlBase() string {
return fmt.Sprintf("https://github.com/%s/%s/pull/", g.RepoOwner, g.RepoName)
}