blob: bd5101070e1054b2dc1b7ad727d465c5f1a67dbb [file] [log] [blame]
package gerrit
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/cenkalti/backoff"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
)
var (
// ErrEmptyChange indicates that a change, if it were to be made, would be
// empty (i.e. have no modified files).
ErrEmptyChange = errors.New("resultant change would be empty")
)
// EditChange is a helper for creating a new patch set on an existing
// Change. Pass in a function which creates and modifies a ChangeEdit, and the
// result will be automatically published as a new patch set, or in the case of
// failure, reverted.
func EditChange(ctx context.Context, g GerritInterface, ci *ChangeInfo, fn func(context.Context, GerritInterface, *ChangeInfo) error) (rvErr error) {
defer func() {
if rvErr == nil {
rvErr = g.PublishChangeEdit(ctx, ci)
}
if rvErr != nil {
if err := g.DeleteChangeEdit(ctx, ci); err != nil {
rvErr = skerr.Wrapf(rvErr, "failed to edit change and failed to delete edit with: %s", err)
}
}
}()
return fn(ctx, g, ci)
}
// CreateAndEditChange is a helper which creates a new Change in the given
// project based on the given branch with the given commit message. Pass in a
// function which modifies a ChangeEdit, and the result will be automatically
// published as a new patch set, or in the case of failure, reverted. If an
// error is encountered after the Change is created, the ChangeInfo is returned
// so that the caller can decide whether to abandon the change or try again.
func CreateAndEditChange(ctx context.Context, g GerritInterface, project, branch, commitMsg, baseCommit, baseChangeID string, fn func(context.Context, GerritInterface, *ChangeInfo) error) (*ChangeInfo, error) {
splitCommitMsg := strings.Split(commitMsg, "\n")
ci, err := g.CreateChange(ctx, project, branch, splitCommitMsg[0], baseCommit, baseChangeID)
if err != nil {
return nil, skerr.Wrapf(err, "failed to create change")
}
if err := EditChange(ctx, g, ci, func(ctx context.Context, g GerritInterface, ci *ChangeInfo) error {
if len(splitCommitMsg) > 1 {
commitMsg, err = git.AddTrailer(commitMsg, "Change-Id: "+ci.ChangeId)
if err != nil {
return skerr.Wrap(err)
}
if err := g.SetCommitMessage(ctx, ci, commitMsg); err != nil {
return skerr.Wrapf(err, "failed to set commit message to:\n\n%s\n\n", commitMsg)
}
}
return fn(ctx, g, ci)
}); err != nil {
return ci, skerr.Wrapf(err, "failed to edit change")
}
// Update the view of the Change to include the new patchset. Sometimes
// Gerrit lags and doesn't include the second patchset in the response, so
// we use retries with exponential backoff until it shows up or the allotted
// time runs out.
exp := &backoff.ExponentialBackOff{
InitialInterval: time.Second,
RandomizationFactor: 0.5,
Multiplier: 2,
MaxInterval: 16 * time.Second,
MaxElapsedTime: time.Minute,
Clock: backoff.SystemClock,
}
var ci2 *ChangeInfo
loadChange := func() error {
ci2, err = g.GetIssueProperties(ctx, ci.Issue)
if err != nil {
return skerr.Wrapf(err, "failed to retrieve issue properties")
}
if len(ci2.Revisions) < 2 {
sklog.Errorf("Change is missing second patchset; reloading.")
return skerr.Fmt("change is missing second patchset")
}
sklog.Info("Retrieved issue properties successfully.")
return nil
}
return ci2, skerr.Wrap(backoff.Retry(loadChange, exp))
}
// CreateCLWithChanges is a helper which creates a new Change in the given
// project based on the given branch with the given commit message and the given
// map of filepath to new file contents. Empty file contents indicate deletion
// of the file. If reviewers are provided, the change is sent for review.
func CreateCLWithChanges(ctx context.Context, g GerritInterface, project, branch, commitMsg, baseCommit, baseChangeID string, changes map[string]string, reviewers []string) (*ChangeInfo, error) {
ci, err := CreateAndEditChange(ctx, g, project, branch, commitMsg, baseCommit, baseChangeID, func(ctx context.Context, g GerritInterface, ci *ChangeInfo) error {
for filepath, contents := range changes {
if contents == "" {
if err := g.DeleteFile(ctx, ci, filepath); err != nil {
return skerr.Wrap(err)
}
} else {
if err := g.EditFile(ctx, ci, filepath, contents); err != nil {
if !strings.Contains(err.Error(), ErrNoChanges) {
return skerr.Wrap(err)
}
}
}
}
return nil
})
if err != nil {
return nil, skerr.Wrap(err)
}
if len(reviewers) > 0 {
if ci.WorkInProgress {
if err := g.SetReadyForReview(ctx, ci); err != nil {
return ci, skerr.Wrapf(err, "failed to set ready for review")
}
}
if err := g.SetReview(ctx, ci, "", nil, reviewers, "", nil, "", 0, nil); err != nil {
return ci, skerr.Wrapf(err, "failed to set review")
}
}
return ci, nil
}
// CreateCLFromLocalDiffs is a helper which creates a Change based on the
// diffs from the provided branch in a local checkout. Note that the diff is
// performed against the given branch on "origin", and not any local version.
func CreateCLFromLocalDiffs(ctx context.Context, g GerritInterface, project, branch, commitMsg string, reviewers []string, co *git.Checkout) (*ChangeInfo, error) {
baseCommit, err := co.Git(ctx, "rev-parse", fmt.Sprintf("origin/%s", branch))
if err != nil {
return nil, skerr.Wrap(err)
}
baseCommit = strings.TrimSpace(baseCommit)
diff, err := co.Git(ctx, "diff", "--name-only", baseCommit)
if err != nil {
return nil, skerr.Wrap(err)
}
diffSplit := strings.Split(diff, "\n")
changes := make(map[string]string, len(diffSplit))
for _, diffLine := range diffSplit {
if diffLine != "" {
contents, err := os.ReadFile(filepath.Join(co.Dir(), diffLine))
if err != nil {
return nil, skerr.Wrap(err)
}
changes[diffLine] = string(contents)
}
}
if len(changes) == 0 {
return nil, ErrEmptyChange
}
return CreateCLWithChanges(ctx, g, project, branch, commitMsg, baseCommit, "", changes, reviewers)
}