blob: c1029be69cd128b5d09ef7677aaac53c063ca2cc [file] [log] [blame]
// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.acceptance.testsuite.change;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.change.ChangeInserter;
import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.edit.tree.TreeCreator;
import com.google.gerrit.server.edit.tree.TreeModification;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.Merger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.ChangeIdUtil;
/**
* The implementation of {@link ChangeOperations}.
*
* <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the
* separation between interface and implementation to enhance clarity.
*/
public class ChangeOperationsImpl implements ChangeOperations {
private final Sequences seq;
private final ChangeInserter.Factory changeInserterFactory;
private final PatchSetInserter.Factory patchsetInserterFactory;
private final GitRepositoryManager repositoryManager;
private final AccountResolver resolver;
private final IdentifiedUser.GenericFactory userFactory;
private final PersonIdent serverIdent;
private final BatchUpdate.Factory batchUpdateFactory;
private final ProjectCache projectCache;
private final ChangeFinder changeFinder;
private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
private final PerCommentOperationsImpl.Factory perCommentOperationsFactory;
private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory;
private final PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory;
@Inject
public ChangeOperationsImpl(
Sequences seq,
ChangeInserter.Factory changeInserterFactory,
PatchSetInserter.Factory patchsetInserterFactory,
GitRepositoryManager repositoryManager,
AccountResolver resolver,
IdentifiedUser.GenericFactory userFactory,
@GerritPersonIdent PersonIdent serverIdent,
BatchUpdate.Factory batchUpdateFactory,
ProjectCache projectCache,
ChangeFinder changeFinder,
PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory,
PerCommentOperationsImpl.Factory perCommentOperationsFactory,
PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory,
PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory) {
this.seq = seq;
this.changeInserterFactory = changeInserterFactory;
this.patchsetInserterFactory = patchsetInserterFactory;
this.repositoryManager = repositoryManager;
this.resolver = resolver;
this.userFactory = userFactory;
this.serverIdent = serverIdent;
this.batchUpdateFactory = batchUpdateFactory;
this.projectCache = projectCache;
this.changeFinder = changeFinder;
this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
this.perCommentOperationsFactory = perCommentOperationsFactory;
this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory;
}
@Override
public PerChangeOperations change(Change.Id changeId) {
return new PerChangeOperationsImpl(changeId);
}
@Override
public TestChangeCreation.Builder newChange() {
return TestChangeCreation.builder(this::createChange);
}
private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
Change.Id changeId = Change.id(seq.nextChangeId());
Project.NameKey project = getTargetProject(changeCreation);
try (Repository repository = repositoryManager.openRepository(project);
ObjectInserter objectInserter = repository.newObjectInserter();
RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
Instant now = TimeUtil.now();
IdentifiedUser changeOwner = getChangeOwner(changeCreation);
PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
ObjectId commitId =
createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
String refName = RefNames.fullName(changeCreation.branch());
ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
inserter.setApprovals(changeCreation.approvals());
try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
batchUpdate.setRepository(repository, revWalk, objectInserter);
batchUpdate.insertChange(inserter);
batchUpdate.execute();
}
return changeId;
}
}
private Project.NameKey getTargetProject(TestChangeCreation changeCreation) {
if (changeCreation.project().isPresent()) {
return changeCreation.project().get();
}
return getArbitraryProject();
}
private Project.NameKey getArbitraryProject() {
Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey();
Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey();
Optional<Project.NameKey> arbitraryProject =
projectCache.all().stream()
.filter(
name ->
!Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName))
.findFirst();
checkState(
arbitraryProject.isPresent(),
"At least one repository must be available on the Gerrit server");
return arbitraryProject.get();
}
private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
throws IOException, ConfigInvalidException {
if (changeCreation.owner().isPresent()) {
return userFactory.create(changeCreation.owner().get());
}
return getArbitraryUser();
}
private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
checkState(
!foundAccounts.isEmpty(),
"At least one user account must be available on the Gerrit server");
return userFactory.create(foundAccounts.iterator().next());
}
private ObjectId createCommit(
Repository repository,
RevWalk revWalk,
ObjectInserter objectInserter,
TestChangeCreation changeCreation,
PersonIdent authorAndCommitter)
throws IOException, BadRequestException {
ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
TreeCreator treeCreator =
getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
String commitMessage = correctCommitMessage(changeCreation.commitMessage());
return createCommit(
objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
}
private ImmutableList<ObjectId> getParentCommits(
Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
return changeCreation
.parents()
.map(parents -> resolveParents(repository, revWalk, parents))
.orElseGet(() -> asImmutableList(getTip(repository, changeCreation.branch())));
}
private ImmutableList<ObjectId> resolveParents(
Repository repository, RevWalk revWalk, ImmutableList<TestCommitIdentifier> parents) {
return parents.stream()
.map(parent -> resolveCommit(repository, revWalk, parent))
.collect(toImmutableList());
}
private ObjectId resolveCommit(
Repository repository, RevWalk revWalk, TestCommitIdentifier parentCommit) {
switch (parentCommit.getKind()) {
case BRANCH:
return resolveBranchTip(repository, parentCommit.branch());
case CHANGE_ID:
return resolveChange(parentCommit.changeId());
case COMMIT_SHA_1:
return resolveCommitFromSha1(revWalk, parentCommit.commitSha1());
case PATCHSET_ID:
return resolvePatchset(parentCommit.patchsetId());
default:
throw new IllegalStateException(
String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
}
}
private static ObjectId resolveBranchTip(Repository repository, String branchName) {
return getTip(repository, branchName)
.orElseThrow(
() ->
new IllegalStateException(
String.format(
"Tip of branch %s not found and hence can't be used as parent.",
branchName)));
}
private static Optional<ObjectId> getTip(Repository repository, String branch) {
try {
Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
return ref.map(Ref::getObjectId);
} catch (IOException e) {
throw new StorageException(e);
}
}
private ObjectId resolveChange(Change.Id changeId) {
Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
return changeNotes
.map(ChangeNotes::getCurrentPatchSet)
.map(PatchSet::commitId)
.orElseThrow(
() ->
new IllegalStateException(
String.format(
"Change %s not found and hence can't be used as parent.", changeId)));
}
private static RevCommit resolveCommitFromSha1(RevWalk revWalk, ObjectId commitSha1) {
try {
return revWalk.parseCommit(commitSha1);
} catch (Exception e) {
throw new IllegalStateException(
String.format("Commit %s not found and hence can't be used as parent/base.", commitSha1),
e);
}
}
private ObjectId resolvePatchset(PatchSet.Id patchsetId) {
Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
return changeNotes
.map(ChangeNotes::getPatchSets)
.map(patchsets -> patchsets.get(patchsetId))
.map(PatchSet::commitId)
.orElseThrow(
() ->
new IllegalStateException(
String.format(
"Patchset %s not found and hence can't be used as parent.", patchsetId)));
}
private static <T> ImmutableList<T> asImmutableList(Optional<T> value) {
return Streams.stream(value).collect(toImmutableList());
}
private static TreeCreator getTreeCreator(
RevWalk revWalk, ObjectId customBaseCommit, ImmutableList<ObjectId> parentCommits) {
RevCommit commit = resolveCommitFromSha1(revWalk, customBaseCommit);
// Use actual parents; relevant for example when a file is restored (->
// RestoreFileModification).
return TreeCreator.basedOnTree(commit.getTree(), parentCommits);
}
private static TreeCreator getTreeCreator(
ObjectInserter objectInserter,
ImmutableList<ObjectId> parentCommits,
MergeStrategy mergeStrategy) {
if (parentCommits.isEmpty()) {
return TreeCreator.basedOnEmptyTree();
}
ObjectId baseTreeId = merge(objectInserter, parentCommits, mergeStrategy);
return TreeCreator.basedOnTree(baseTreeId, parentCommits);
}
private static ObjectId merge(
ObjectInserter objectInserter,
ImmutableList<ObjectId> parentCommits,
MergeStrategy mergeStrategy) {
try {
Merger merger = mergeStrategy.newMerger(objectInserter, new Config());
boolean mergeSuccessful = merger.merge(parentCommits.toArray(new AnyObjectId[0]));
if (!mergeSuccessful) {
throw new IllegalStateException(
"Conflicts encountered while merging the specified parents. Use"
+ " mergeOfButBaseOnFirst() instead to avoid these conflicts and define any"
+ " other desired file contents with file().content().");
}
return merger.getResultTreeId();
} catch (IOException e) {
throw new IllegalStateException(
"Creating the merge commits of the specified parents failed for an unknown reason.", e);
}
}
private static ObjectId createNewTree(
Repository repository,
TreeCreator treeCreator,
ImmutableList<TreeModification> treeModifications)
throws IOException {
treeCreator.addTreeModifications(treeModifications);
return treeCreator.createNewTreeAndGetId(repository);
}
private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
ObjectId id = CommitMessageUtil.generateChangeId();
commitMessage = ChangeIdUtil.insertId(commitMessage, id);
}
return commitMessage;
}
private ObjectId createCommit(
ObjectInserter objectInserter,
ObjectId tree,
ImmutableList<ObjectId> parentCommitIds,
PersonIdent author,
PersonIdent committer,
String commitMessage)
throws IOException {
CommitBuilder builder = new CommitBuilder();
builder.setTreeId(tree);
builder.setParentIds(parentCommitIds);
builder.setAuthor(author);
builder.setCommitter(committer);
builder.setMessage(commitMessage);
ObjectId newCommitId = objectInserter.insert(builder);
objectInserter.flush();
return newCommitId;
}
private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) {
ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName);
inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get()));
return inserter;
}
private class PerChangeOperationsImpl implements PerChangeOperations {
private final Change.Id changeId;
public PerChangeOperationsImpl(Change.Id changeId) {
this.changeId = changeId;
}
@Override
public boolean exists() {
return changeFinder.findOne(changeId).isPresent();
}
@Override
public TestChange get() {
return toTestChange(getChangeNotes().getChange());
}
private ChangeNotes getChangeNotes() {
Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
checkState(changeNotes.isPresent(), "Tried to get non-existing test change.");
return changeNotes.get();
}
private TestChange toTestChange(Change change) {
return TestChange.builder()
.numericChangeId(change.getId())
.changeId(change.getKey().get())
.build();
}
@Override
public TestPatchsetCreation.Builder newPatchset() {
return TestPatchsetCreation.builder(this::createPatchset);
}
private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
throws IOException, RestApiException, UpdateException {
ChangeNotes changeNotes = getChangeNotes();
Project.NameKey project = changeNotes.getProjectName();
try (Repository repository = repositoryManager.openRepository(project);
ObjectInserter objectInserter = repository.newObjectInserter();
RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
Instant now = TimeUtil.now();
ObjectId newPatchsetCommit =
createPatchsetCommit(
repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
PatchSet.Id patchsetId =
ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
PatchSetInserter patchSetInserter =
getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
batchUpdate.setRepository(repository, revWalk, objectInserter);
batchUpdate.addOp(changeId, patchSetInserter);
batchUpdate.execute();
}
return patchsetId;
}
}
private ObjectId createPatchsetCommit(
Repository repository,
RevWalk revWalk,
ObjectInserter objectInserter,
ChangeNotes changeNotes,
TestPatchsetCreation patchsetCreation,
Instant now)
throws IOException, BadRequestException {
ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
ImmutableList<ObjectId> parentCommitIds =
getParents(repository, revWalk, patchsetCreation, oldPatchsetCommit);
TreeCreator treeCreator = getTreeCreator(revWalk, oldPatchsetCommit, parentCommitIds);
ObjectId tree = createNewTree(repository, treeCreator, patchsetCreation.treeModifications());
String commitMessage =
correctCommitMessage(
changeNotes.getChange().getKey().get(),
patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
PersonIdent author = getAuthor(oldPatchsetCommit);
PersonIdent committer = getCommitter(oldPatchsetCommit, now);
return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
}
private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
throws BadRequestException {
String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
// Remove initial 'I' and treat the rest as ObjectId. This is not the cleanest approach but
// unfortunately, we don't seem to have other utility code which takes the string-based
// change-id and ensures that it is part of the commit message.
ObjectId id = ObjectId.fromString(oldChangeId.substring(1));
commitMessage = ChangeIdUtil.insertId(commitMessage, id, false);
return commitMessage;
}
private PersonIdent getAuthor(RevCommit oldPatchsetCommit) {
return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
}
private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
PersonIdent oldPatchsetCommitter =
Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
/* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
* In real situations, this automatically happens as two patchsets won't have exactly the
* same commit timestamp even when the tree and commit message are the same. In tests,
* we can easily end up with the same timestamp as Git uses second precision for timestamps.
* We could of course require that tests must use TestTimeUtil#setClockStep but
* that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
* here and simply add a second. */
now = now.plusSeconds(1);
}
return new PersonIdent(oldPatchsetCommitter, now);
}
private long asSeconds(Instant date) {
return date.getEpochSecond();
}
private ImmutableList<ObjectId> getParents(
Repository repository,
RevWalk revWalk,
TestPatchsetCreation patchsetCreation,
RevCommit oldPatchsetCommit) {
return patchsetCreation
.parents()
.map(parents -> resolveParents(repository, revWalk, parents))
.orElseGet(
() -> Arrays.stream(oldPatchsetCommit.getParents()).collect(toImmutableList()));
}
private PatchSetInserter getPatchSetInserter(
ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) {
PatchSetInserter patchSetInserter =
patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit);
patchSetInserter.setCheckAddPatchSetPermission(false);
patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get()));
return patchSetInserter;
}
@Override
public PerPatchsetOperations patchset(PatchSet.Id patchsetId) {
return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId);
}
@Override
public PerPatchsetOperations currentPatchset() {
ChangeNotes changeNotes = getChangeNotes();
return perPatchsetOperationsFactory.create(
changeNotes, changeNotes.getChange().currentPatchSetId());
}
@Override
public PerCommentOperations comment(String commentUuid) {
ChangeNotes changeNotes = getChangeNotes();
return perCommentOperationsFactory.create(changeNotes, commentUuid);
}
@Override
public PerDraftCommentOperations draftComment(String commentUuid) {
ChangeNotes changeNotes = getChangeNotes();
return perDraftCommentOperationsFactory.create(changeNotes, commentUuid);
}
@Override
public PerRobotCommentOperations robotComment(String commentUuid) {
ChangeNotes changeNotes = getChangeNotes();
return perRobotCommentOperationsFactory.create(changeNotes, commentUuid);
}
}
}