blob: ac8f592359eafaea4d5a8c9f5a956b3760645c95 [file] [log] [blame]
package repo_manager
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"path"
"regexp"
"strings"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.skia.org/infra/autoroll/go/codereview"
"go.skia.org/infra/autoroll/go/config_vars"
"go.skia.org/infra/autoroll/go/revision"
"go.skia.org/infra/go/buildbucket"
"go.skia.org/infra/go/depot_tools"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/github"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
)
const (
TMPL_COMMIT_MSG_GITHUB = `Roll {{.ChildPath}} {{.RollingFrom.String}}..{{.RollingTo.String}} ({{len .Revisions}} commits)
{{.ChildRepo}}/compare/{{.RollingFrom.String}}...{{.RollingTo.String}}
{{if .IncludeLog}}git log {{.RollingFrom}}..{{.RollingTo}} --first-parent --oneline
{{range .Revisions}}{{.Timestamp.Format "2006-01-02"}} {{.Author}} {{.Description}}
{{end}}{{end}}{{if len .TransitiveDeps}}
Also rolling transitive DEPS:
{{range .TransitiveDeps}} {{.ParentPath}} {{.RollingFrom}}..{{.RollingTo}}
{{end}}{{end}}
If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
{{.ServerURL}}
Please CC {{stringsJoin .Reviewers ","}} on the revert to ensure that a human
is aware of the problem.
To report a problem with the AutoRoller itself, please file a bug:
https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug
Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+/master/autoroll/README.md
`
)
var (
pullRequestInLogRE = regexp.MustCompile(`(?m) \((#[0-9]+)\)$`)
)
// GithubRepoManagerConfig provides configuration for the Github RepoManager.
type GithubRepoManagerConfig struct {
CommonRepoManagerConfig
// URL of the child repo.
ChildRepoURL string `json:"childRepoURL"`
// The roller will update this file with the child repo's revision.
RevisionFile string `json:"revisionFile"`
BuildbucketRevisionFilter *BuildbucketRevisionFilterConfig `json:"buildbucketFilter"`
// Optional; transitive dependencies to roll. This is a mapping of
// dependencies of the child repo which are also dependencies of the
// parent repo and should be rolled at the same time. Keys are paths
// to transitive dependencies within the child repo (as specified in
// DEPS), and values are paths to the files that must be updated within
// the parent repo. The files just contain the revision ID
// (eg: https://github.com/flutter/flutter/pull/51569/files).
TransitiveDeps map[string]string `json:"transitiveDeps"`
}
type BuildbucketRevisionFilterConfig struct {
Project string `json:"project"`
Bucket string `json:"bucket"`
}
// githubRepoManager is a struct used by the autoroller for managing checkouts.
type githubRepoManager struct {
*commonRepoManager
githubClient *github.GitHub
parentRepo *git.Checkout
parentRepoURL string
childRepoURL string
revisionFile string
revFilter RevisionFilter
transitiveDeps map[string]string
// Will be used for getdeps if transitive dependences are specified in the
// config.
depotTools string
}
// Refactor this out to commonRepoManager one day to be able to define any
// filter for any roller.
type RevisionFilter interface {
// Skip returns a non-empty string if the revision should be skipped. The
// string will contain the reason the revision should be skipped. An empty
// string is returned if the revision should not be skipped.
// If an error is returned then an empty string will be returned.
Skip(context.Context, *revision.Revision) (string, error)
}
type bbRevisionFilter struct {
bb buildbucket.BuildBucketInterface
project string
bucket string
}
// See RevisionFilter interface.
func (f bbRevisionFilter) Skip(ctx context.Context, r *revision.Revision) (string, error) {
pred := &buildbucketpb.BuildPredicate{
Builder: &buildbucketpb.BuilderID{Project: f.project, Bucket: f.bucket},
Tags: []*buildbucketpb.StringPair{
{Key: "buildset", Value: fmt.Sprintf("commit/git/%s", r.Id)},
},
}
builds, err := f.bb.Search(ctx, pred)
if err != nil {
return "", err
}
if len(builds) == 0 {
sklog.Infof("[bbFilter] Builds for %s have not started yet", r.Id)
return fmt.Sprintf("Builds have not started yet"), nil
}
// statuses stores the statuses of builders. This is used to account for luci build retries.
// It is used to determine if there was any successful build for a builder. We should have ideally used
// the most recent status but there appears to be strange behavior with flutter luci builds where
// INFRA_FAILURE builds appear to be coming after SUCCESSFUL builds. Eg:
// https://cr-buildbucket.appspot.com/rpcexplorer/services/buildbucket.v2.Builds/SearchBuilds?request={"predicate":{"builder":{"project": "flutter","bucket": "prod"},"tags":[{"key": "buildset","value": "commit/git/18962926012965f815c273e58409cda3144998f5"}]}}
// This has been brought up with the flutter team.
statuses := map[string]buildbucketpb.Status{}
for _, build := range builds {
prev, ok := statuses[build.Builder.Builder]
if !ok || prev != buildbucketpb.Status_SUCCESS {
statuses[build.Builder.Builder] = build.Status
}
}
for b, status := range statuses {
if status == buildbucketpb.Status_SUCCESS {
sklog.Infof("[bbFilter] Found successful build of \"%s\" for %s", b, r.Id)
} else {
sklog.Infof("[bbFilter] Could not find successful build of \"%s\" for %s: %s", b, r.Id, status)
return fmt.Sprintf("Luci builds of \"%s\" for %s was %s", b, r.Id, status), nil
}
}
sklog.Infof("[bbFilter] All builds of %s were %s", r.Id, buildbucketpb.Status_SUCCESS)
return "", nil
}
func newBuildbucketRevisionFilter(client *http.Client, project, bucket string) (*bbRevisionFilter, error) {
if project == "" || bucket == "" {
return nil, errors.New("Both project and bucket must be specified for buildbucketFilter.")
}
return &bbRevisionFilter{
bb: buildbucket.NewClient(client),
project: project,
bucket: bucket,
}, nil
}
// NewGithubRepoManager returns a RepoManager instance which operates in the given
// working directory and updates at the given frequency.
func NewGithubRepoManager(ctx context.Context, c *GithubRepoManagerConfig, reg *config_vars.Registry, workdir string, githubClient *github.GitHub, recipeCfgFile, serverURL string, client *http.Client, cr codereview.CodeReview, local bool) (RepoManager, error) {
if err := c.Validate(); err != nil {
return nil, err
}
wd := path.Join(workdir, "github_repos")
if _, err := os.Stat(wd); err != nil {
if err := os.MkdirAll(wd, 0755); err != nil {
return nil, err
}
}
// Create and populate the parent directory if needed.
_, repo := GetUserAndRepo(c.ParentRepo)
userFork := fmt.Sprintf("git@github.com:%s/%s.git", cr.UserName(), repo)
parentRepo, err := git.NewCheckout(ctx, userFork, wd)
if err != nil {
return nil, err
}
if c.CommitMsgTmpl == "" {
c.CommitMsgTmpl = TMPL_COMMIT_MSG_GITHUB
}
crm, err := newCommonRepoManager(ctx, c.CommonRepoManagerConfig, reg, wd, serverURL, nil, client, cr, local)
if err != nil {
return nil, err
}
// Create and populate the child directory if needed.
if _, err := os.Stat(crm.childDir); err != nil {
if err := os.MkdirAll(crm.childDir, 0755); err != nil {
return nil, err
}
if _, err := git.GitDir(crm.childDir).Git(ctx, "clone", c.ChildRepoURL, "."); err != nil {
return nil, err
}
}
var f RevisionFilter
if c.BuildbucketRevisionFilter != nil {
f, err = newBuildbucketRevisionFilter(client, c.BuildbucketRevisionFilter.Project, c.BuildbucketRevisionFilter.Bucket)
if err != nil {
return nil, err
}
}
depotTools, err := depot_tools.GetDepotTools(ctx, workdir, recipeCfgFile)
if err != nil {
return nil, err
}
gr := &githubRepoManager{
commonRepoManager: crm,
githubClient: githubClient,
parentRepo: parentRepo,
parentRepoURL: c.ParentRepo,
childRepoURL: c.ChildRepoURL,
revisionFile: c.RevisionFile,
revFilter: f,
transitiveDeps: c.TransitiveDeps,
depotTools: depotTools,
}
return gr, nil
}
// Fix pull request linkification in the commit details.
func (rm *githubRepoManager) fixPullRequestLinks(rev *revision.Revision) {
user, repo := GetUserAndRepo(rm.childRepoURL)
// Github autolinks PR numbers to be of the same repository in logStr. Fix this by
// explicitly adding the child repo to the PR number.
rev.Description = pullRequestInLogRE.ReplaceAllString(rev.Description, fmt.Sprintf(" (%s/%s$1)", user, repo))
rev.Details = pullRequestInLogRE.ReplaceAllString(rev.Details, fmt.Sprintf(" (%s/%s$1)", user, repo))
}
// See documentation for RepoManager interface.
func (rm *githubRepoManager) Update(ctx context.Context) (*revision.Revision, *revision.Revision, []*revision.Revision, error) {
// Sync the projects.
rm.repoMtx.Lock()
defer rm.repoMtx.Unlock()
// Update the repositories.
if err := rm.parentRepo.Update(ctx); err != nil {
return nil, nil, nil, err
}
if err := rm.childRepo.Update(ctx); err != nil {
return nil, nil, nil, err
}
// Check to see whether there is an upstream yet.
remoteOutput, err := rm.parentRepo.Git(ctx, "remote", "show")
if err != nil {
return nil, nil, nil, err
}
remoteFound := false
remoteLines := strings.Split(remoteOutput, "\n")
for _, remoteLine := range remoteLines {
if remoteLine == GITHUB_UPSTREAM_REMOTE_NAME {
remoteFound = true
break
}
}
if !remoteFound {
if _, err := rm.parentRepo.Git(ctx, "remote", "add", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentRepoURL); err != nil {
return nil, nil, nil, err
}
}
// Fetch upstream.
if _, err := rm.parentRepo.Git(ctx, "fetch", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch.String()); err != nil {
return nil, nil, nil, err
}
// Read the contents of the revision file to determine the last roll rev.
revisionFileContents, err := rm.githubClient.ReadRawFile(rm.parentBranch.String(), rm.revisionFile)
if err != nil {
return nil, nil, nil, err
}
lastRollHash := strings.TrimRight(revisionFileContents, "\n")
lastRollDetails, err := rm.childRepo.Details(ctx, lastRollHash)
if err != nil {
return nil, nil, nil, err
}
lastRollRev := revision.FromLongCommit(rm.childRevLinkTmpl, lastRollDetails)
rm.fixPullRequestLinks(lastRollRev)
// Get the tip-of-tree revision. Because we filter the notRolledRevs,
// this may not end up being present in that list.
tipRev, err := rm.getTipRev(ctx)
if err != nil {
return nil, nil, nil, err
}
rm.fixPullRequestLinks(tipRev)
// Find the not-rolled child repo commits.
notRolledRevs, err := rm.getCommitsNotRolled(ctx, lastRollRev, tipRev)
if err != nil {
return nil, nil, nil, err
}
for _, rev := range notRolledRevs {
rm.fixPullRequestLinks(rev)
}
// Optionally filter not-rolled revisions.
if rm.revFilter != nil {
for _, notRolledRev := range notRolledRevs {
invalidReason, err := rm.revFilter.Skip(ctx, notRolledRev)
if err != nil {
return nil, nil, nil, err
}
if invalidReason != "" {
notRolledRev.InvalidReason = invalidReason
}
}
}
return lastRollRev, tipRev, notRolledRevs, nil
}
func (rm *githubRepoManager) cleanParent(ctx context.Context) error {
if _, err := rm.parentRepo.Git(ctx, "clean", "-d", "-f", "-f"); err != nil {
return err
}
_, _ = rm.parentRepo.Git(ctx, "rebase", "--abort")
if _, err := rm.parentRepo.Git(ctx, "checkout", fmt.Sprintf("%s/%s", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch), "-f"); err != nil {
return err
}
_, _ = rm.parentRepo.Git(ctx, "branch", "-D", ROLL_BRANCH)
return nil
}
// See documentation for RepoManager interface.
func (rm *githubRepoManager) CreateNewRoll(ctx context.Context, from, to *revision.Revision, rolling []*revision.Revision, emails []string, cqExtraTrybots string, dryRun bool) (int64, error) {
rm.repoMtx.Lock()
defer rm.repoMtx.Unlock()
sklog.Info("Creating a new Github Roll")
// Clean the checkout, get onto a fresh branch.
if err := rm.cleanParent(ctx); err != nil {
return 0, err
}
if _, err := rm.parentRepo.Git(ctx, "checkout", fmt.Sprintf("%s/%s", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch), "-b", ROLL_BRANCH); err != nil {
return 0, err
}
// Defer cleanup.
defer func() {
util.LogErr(rm.cleanParent(ctx))
}()
// Make sure the forked repo is at the same hash as the target repo before
// creating the pull request.
if _, err := rm.parentRepo.Git(ctx, "push", "origin", ROLL_BRANCH, "-f"); err != nil {
return 0, err
}
// Make sure the right name and email are set.
if !rm.local {
if _, err := rm.parentRepo.Git(ctx, "config", "user.name", rm.codereview.UserName()); err != nil {
return 0, err
}
if _, err := rm.parentRepo.Git(ctx, "config", "user.email", rm.codereview.UserEmail()); err != nil {
return 0, err
}
}
// Build the commit message.
childRepo := strings.ReplaceAll(rm.childRepoURL, "git@github.com:", "https://github.com/")
childRepo = strings.ReplaceAll(childRepo, ".git", "")
commitMsg, err := rm.buildCommitMsg(&CommitMsgVars{
ChildPath: rm.childPath,
ChildRepo: childRepo,
Reviewers: emails,
Revisions: rolling,
RollingFrom: from,
RollingTo: to,
ServerURL: rm.serverURL,
})
for i := len(rolling) - 1; i >= 0; i-- {
// Write the file.
if err := ioutil.WriteFile(path.Join(rm.parentRepo.Dir(), rm.revisionFile), []byte(rolling[i].Id+"\n"), os.ModePerm); err != nil {
return 0, err
}
// Commit.
msg := fmt.Sprintf("%s %s", rolling[i].Id[:9], rolling[i].Description)
if _, err := rm.parentRepo.Git(ctx, "commit", "-a", "-m", msg); err != nil {
return 0, err
}
}
// Update any transitive DEPS.
if len(rm.transitiveDeps) > 0 {
for childPath, parentPath := range rm.transitiveDeps {
output, err := exec.RunCwd(ctx, rm.childRepo.Dir(), "python", path.Join(rm.depotTools, GCLIENT), "getdep", "-r", childPath, "--spec={\"host_os\":\"linux\"}")
if err != nil {
return 0, err
}
targetRev := strings.TrimSpace(output)
// TODO(rmistry): Is this always 44 chars?
if len(targetRev) != 44 {
return 0, fmt.Errorf("Got invalid output for `gclient getdep`: %s", output)
}
// Compare with the already existing contents to see if anything needs to be updated.
parentFilePath := path.Join(rm.parentRepo.Dir(), parentPath)
existingContents, err := ioutil.ReadFile(parentFilePath)
if err != nil {
return 0, err
}
if strings.TrimSpace(string(existingContents)) == targetRev {
sklog.Infof("%s is already in %s. Not going to update it.", targetRev, parentFilePath)
} else {
//Update the file in parentPath with the targetRev.
if err := ioutil.WriteFile(parentFilePath, []byte(targetRev+"\n"), os.ModePerm); err != nil {
return 0, err
}
// Commit.
msg := fmt.Sprintf("Updated %s", parentPath)
if _, err := rm.parentRepo.Git(ctx, "commit", "-a", "-m", msg); err != nil {
return 0, err
}
}
}
}
// Run the pre-upload steps.
for _, s := range rm.preUploadSteps {
if err := s(ctx, nil, rm.httpClient, rm.parentRepo.Dir()); err != nil {
return 0, fmt.Errorf("Error when running pre-upload step: %s", err)
}
}
// Push to the forked repository.
if _, err := rm.parentRepo.Git(ctx, "push", "origin", ROLL_BRANCH, "-f"); err != nil {
return 0, err
}
// Grab the first line of the commit msg to use as the title of the pull request.
title := strings.Split(commitMsg, "\n")[0]
// Use the remaining part of the commit message as the pull request description.
commitMsgLines := strings.Split(commitMsg, "\n")
var descComment []string
if len(commitMsgLines) > 50 {
// Truncate too large description comment because Github API cannot handle large comments.
descComment = commitMsgLines[1:50]
descComment = append(descComment, "...")
} else {
descComment = commitMsgLines[1:]
}
// Create a pull request.
headBranch := fmt.Sprintf("%s:%s", rm.codereview.UserName(), ROLL_BRANCH)
pr, err := rm.githubClient.CreatePullRequest(title, rm.parentBranch.String(), headBranch, strings.Join(descComment, "\n"))
if err != nil {
return 0, err
}
// Add appropriate label to the pull request.
if !dryRun {
if err := rm.githubClient.AddLabel(pr.GetNumber(), github.WAITING_FOR_GREEN_TREE_LABEL); err != nil {
return 0, err
}
}
return int64(pr.GetNumber()), nil
}
func GetUserAndRepo(githubRepo string) (string, string) {
repoTokens := strings.Split(githubRepo, ":")
user := strings.Split(repoTokens[1], "/")[0]
repo := strings.TrimRight(strings.Split(repoTokens[1], "/")[1], ".git")
return user, repo
}