blob: b4020b322d407f2a479618581e2c4c84322d87bc [file] [log] [blame]
package repo_manager
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"os"
"path"
"regexp"
"strings"
"cloud.google.com/go/storage"
"go.skia.org/infra/autoroll/go/codereview"
"go.skia.org/infra/autoroll/go/revision"
"go.skia.org/infra/go/gcs"
"go.skia.org/infra/go/gcs/gcsclient"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/github"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/vcsinfo"
)
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}} --no-merges --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 (
// Use this function to instantiate a NewGithubRepoManager. This is able to be
// overridden for testing.
NewGithubRepoManager func(context.Context, *GithubRepoManagerConfig, string, *github.GitHub, string, string, *http.Client, codereview.CodeReview, bool) (RepoManager, error) = newGithubRepoManager
pullRequestInLogRE = regexp.MustCompile(`(?m) \((#[0-9]+)\)$`)
)
// GithubRepoManagerConfig provides configuration for the Github RepoManager.
type GithubRepoManagerConfig struct {
CommonRepoManagerConfig
// URL of the parent repo.
ParentRepoURL string `json:"parentRepoURL"`
// URL of the child repo.
ChildRepoURL string `json:"childRepoURL"`
// If false, roll CLs do not include a git log.
IncludeLog bool `json:"includeLog"`
// The roller will update this file with the child repo's revision.
RevisionFile string `json:"revisionFile"`
// GCS bucket to use if filtering revisions by presence of files in GCS.
StorageBucket string `json:"storageBucket"`
// Templates of GCS files to look for; if provided, revisions to roll
// will be filtered by the presence of these files in GCS.
StoragePathTemplates []string `json:"storagePathTemplates"`
}
// githubRepoManager is a struct used by the autoroller for managing checkouts.
type githubRepoManager struct {
*commonRepoManager
filterRevisionsByGCS []string
gcs gcs.GCSClient
githubClient *github.GitHub
includeLog bool
parentRepo *git.Checkout
parentRepoURL string
childRepoURL string
revisionFile string
gsBucket string
gsPathTemplates []string
}
// 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, 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.ParentRepoURL)
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, 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 gcsClient gcs.GCSClient
if len(c.StoragePathTemplates) > 0 {
storageClient, err := storage.NewClient(ctx)
if err != nil {
return nil, err
}
gcsClient = gcsclient.New(storageClient, c.StorageBucket)
}
gr := &githubRepoManager{
commonRepoManager: crm,
gcs: gcsClient,
githubClient: githubClient,
includeLog: c.IncludeLog,
parentRepo: parentRepo,
parentRepoURL: c.ParentRepoURL,
childRepoURL: c.ChildRepoURL,
revisionFile: c.RevisionFile,
gsBucket: c.StorageBucket,
gsPathTemplates: c.StoragePathTemplates,
}
return gr, nil
}
// 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); 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, 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)
// 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
}
// Find the not-rolled child repo commits.
notRolledRevs, err := rm.getCommitsNotRolled(ctx, lastRollRev, tipRev)
if err != nil {
return nil, nil, nil, err
}
// Optionally filter not-rolled revisions by the existence of matching
// files in GCS.
if len(rm.gsPathTemplates) > 0 {
if len(notRolledRevs) > 0 {
filtered := make([]*revision.Revision, 0, len(notRolledRevs))
for _, notRolledRev := range notRolledRevs {
// Check to see if this commit exists in the gsPath locations.
missingFile := false
for _, gsPathTemplate := range rm.gsPathTemplates {
gsPath := fmt.Sprintf(gsPathTemplate, notRolledRev.Id)
fileExists, err := rm.gcs.DoesFileExist(ctx, gsPath)
if err != nil {
return nil, nil, nil, err
}
if fileExists {
sklog.Infof("[gcsFileFilter] Found %s", gsPath)
continue
} else {
sklog.Infof("[gcsFileFilter] Could not find %s", gsPath)
missingFile = true
break
}
}
if !missingFile {
sklog.Infof("[gcsFileFilter] Found all %s paths for %s", rm.gsPathTemplates, notRolledRev.Id)
filtered = append(filtered, notRolledRev)
}
}
// We may have filtered out tipRev. For consistency's sake, it
// needs to be in notRolledRevs, so pick the latest rev and use
// that.
if len(filtered) == 0 {
sklog.Warningf("Filtered out tip revision %s, and no notRolledRevs remain; using last roll rev %s", tipRev.Id, lastRollRev.Id)
tipRev = lastRollRev
} else if filtered[0].Id != tipRev.Id {
sklog.Warningf("Filtered out tip revision %s; using %s instead.", tipRev.Id, filtered[0].Id)
tipRev = filtered[0]
}
notRolledRevs = filtered
}
}
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.
user, repo := GetUserAndRepo(rm.childRepoURL)
details := make([]*vcsinfo.LongCommit, 0, len(rolling))
for _, c := range rolling {
d, err := rm.childRepo.Details(ctx, c.Id)
if err != nil {
return 0, fmt.Errorf("Failed to get commit details: %s", err)
}
// Github autolinks PR numbers to be of the same repository in logStr. Fix this by
// explicitly adding the child repo to the PR number.
d.Subject = pullRequestInLogRE.ReplaceAllString(d.Subject, fmt.Sprintf(" (%s/%s$1)", user, repo))
d.Body = pullRequestInLogRE.ReplaceAllString(d.Body, fmt.Sprintf(" (%s/%s$1)", user, repo))
details = append(details, d)
}
commitMsg, err := rm.buildCommitMsg(&CommitMsgVars{
ChildPath: rm.childPath,
ChildRepo: rm.childRepoURL,
IncludeLog: rm.includeLog,
Reviewers: emails,
Revisions: rolling,
RollingFrom: from,
RollingTo: to,
ServerURL: rm.serverURL,
})
for i := len(details) - 1; i >= 0; i-- {
// Write the file.
if err := ioutil.WriteFile(path.Join(rm.parentRepo.Dir(), rm.revisionFile), []byte(details[i].Hash+"\n"), os.ModePerm); err != nil {
return 0, err
}
// Commit.
msg := fmt.Sprintf("%s %s", details[i].Hash[:9], details[i].Subject)
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, headBranch, strings.Join(descComment, "\n"))
if err != nil {
return 0, err
}
// Add appropriate label to the pull request.
label := github.COMMIT_LABEL
if dryRun {
label = github.DRYRUN_LABEL
}
if err := rm.githubClient.AddLabel(pr.GetNumber(), 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
}