blob: 22bdae506f7a0865678ca1e8a0d3a7b38c51b5bc [file] [log] [blame]
// Copyright (C) 2012 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.server.change;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Predicate;
import com.google.common.base.Strings;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.google.gerrit.common.data.ParameterizedString;
import com.google.gerrit.extensions.api.changes.SubmitInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.webui.UiAction;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.ProjectUtil;
import com.google.gerrit.server.account.AccountsCollection;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.ChangeSet;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MergeOp;
import com.google.gerrit.server.git.MergeSuperSet;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.OrmRuntimeException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Singleton
public class Submit implements RestModifyView<RevisionResource, SubmitInput>,
UiAction<RevisionResource> {
private static final Logger log = LoggerFactory.getLogger(Submit.class);
private static final String DEFAULT_TOOLTIP =
"Submit patch set ${patchSet} into ${branch}";
private static final String DEFAULT_TOOLTIP_ANCESTORS =
"Submit patch set ${patchSet} and ancestors (${submitSize} changes " +
"altogether) into ${branch}";
private static final String DEFAULT_TOPIC_TOOLTIP =
"Submit all ${topicSize} changes of the same topic " +
"(${submitSize} changes including ancestors and other " +
"changes related by topic)";
private static final String BLOCKED_SUBMIT_TOOLTIP =
"This change depends on other changes which are not ready";
private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
"This change depends on other hidden changes which are not ready";
private static final String CLICK_FAILURE_TOOLTIP =
"Clicking the button would fail";
private static final String CHANGE_UNMERGEABLE =
"Problems with integrating this change";
private static final String CHANGES_NOT_MERGEABLE =
"Problems with change(s): ";
public static class Output {
transient Change change;
private Output(Change c) {
change = c;
}
}
private final Provider<ReviewDb> dbProvider;
private final GitRepositoryManager repoManager;
private final ChangeData.Factory changeDataFactory;
private final ChangeMessagesUtil cmUtil;
private final ChangeControl.GenericFactory changeControlFactory;
private final Provider<MergeOp> mergeOpProvider;
private final MergeSuperSet mergeSuperSet;
private final AccountsCollection accounts;
private final ChangesCollection changes;
private final String label;
private final String labelWithParents;
private final ParameterizedString titlePattern;
private final ParameterizedString titlePatternWithAncestors;
private final String submitTopicLabel;
private final ParameterizedString submitTopicTooltip;
private final boolean submitWholeTopic;
private final Provider<InternalChangeQuery> queryProvider;
@Inject
Submit(Provider<ReviewDb> dbProvider,
GitRepositoryManager repoManager,
ChangeData.Factory changeDataFactory,
ChangeMessagesUtil cmUtil,
ChangeControl.GenericFactory changeControlFactory,
Provider<MergeOp> mergeOpProvider,
MergeSuperSet mergeSuperSet,
AccountsCollection accounts,
ChangesCollection changes,
@GerritServerConfig Config cfg,
Provider<InternalChangeQuery> queryProvider) {
this.dbProvider = dbProvider;
this.repoManager = repoManager;
this.changeDataFactory = changeDataFactory;
this.cmUtil = cmUtil;
this.changeControlFactory = changeControlFactory;
this.mergeOpProvider = mergeOpProvider;
this.mergeSuperSet = mergeSuperSet;
this.accounts = accounts;
this.changes = changes;
this.label = MoreObjects.firstNonNull(
Strings.emptyToNull(cfg.getString("change", null, "submitLabel")),
"Submit");
this.labelWithParents = MoreObjects.firstNonNull(
Strings.emptyToNull(
cfg.getString("change", null, "submitLabelWithParents")),
"Submit including parents");
this.titlePattern = new ParameterizedString(MoreObjects.firstNonNull(
cfg.getString("change", null, "submitTooltip"),
DEFAULT_TOOLTIP));
this.titlePatternWithAncestors = new ParameterizedString(
MoreObjects.firstNonNull(
cfg.getString("change", null, "submitTooltipAncestors"),
DEFAULT_TOOLTIP_ANCESTORS));
submitWholeTopic = wholeTopicEnabled(cfg);
this.submitTopicLabel = MoreObjects.firstNonNull(
Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
"Submit whole topic");
this.submitTopicTooltip = new ParameterizedString(MoreObjects.firstNonNull(
cfg.getString("change", null, "submitTopicTooltip"),
DEFAULT_TOPIC_TOOLTIP));
this.queryProvider = queryProvider;
}
@Override
public Output apply(RevisionResource rsrc, SubmitInput input)
throws AuthException, ResourceConflictException,
RepositoryNotFoundException, IOException, OrmException,
UnprocessableEntityException {
input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
if (input.onBehalfOf != null) {
rsrc = onBehalfOf(rsrc, input);
}
ChangeControl control = rsrc.getControl();
IdentifiedUser caller = control.getUser().asIdentifiedUser();
Change change = rsrc.getChange();
if (input.onBehalfOf == null && !control.canSubmit()) {
throw new AuthException("submit not permitted");
} else if (!change.getStatus().isOpen()) {
throw new ResourceConflictException("change is " + status(change));
} else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
throw new ResourceConflictException(String.format(
"destination branch \"%s\" not found.",
change.getDest().get()));
} else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
// TODO Allow submitting non-current revision by changing the current.
throw new ResourceConflictException(String.format(
"revision %s is not current revision",
rsrc.getPatchSet().getRevision().get()));
}
try {
ReviewDb db = dbProvider.get();
mergeOpProvider.get().merge(db, change, caller, true);
change = db.changes().get(change.getId());
} catch (NoSuchChangeException e) {
throw new OrmException("Submission failed", e);
}
if (change == null) {
throw new ResourceConflictException("change is deleted");
}
switch (change.getStatus()) {
case MERGED:
return new Output(change);
case NEW:
ChangeMessage msg = getConflictMessage(rsrc);
if (msg != null) {
throw new ResourceConflictException(msg.getMessage());
}
//$FALL-THROUGH$
default:
throw new ResourceConflictException("change is " + status(change));
}
}
/**
* @param cd the change the user is currently looking at
* @param cs set of changes to be submitted at once
* @param identifiedUser the user who is checking to submit
* @return a reason why any of the changes is not submittable or null
*/
private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs,
IdentifiedUser identifiedUser) {
try {
@SuppressWarnings("resource")
ReviewDb db = dbProvider.get();
for (PatchSet.Id psId : cs.patchIds()) {
ChangeControl changeControl = changeControlFactory
.controlFor(psId.getParentKey(), identifiedUser);
ChangeData c = changeDataFactory.create(db, changeControl);
if (!changeControl.isVisible(db)) {
return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
}
if (!changeControl.canSubmit()) {
return BLOCKED_SUBMIT_TOOLTIP;
}
MergeOp.checkSubmitRule(c);
}
Collection<ChangeData> unmergeable = unmergeableChanges(cs);
if (unmergeable == null) {
return CLICK_FAILURE_TOOLTIP;
} else if (!unmergeable.isEmpty()) {
for (ChangeData c : unmergeable) {
if (c.change().getKey().equals(cd.change().getKey())) {
return CHANGE_UNMERGEABLE;
}
}
return CHANGES_NOT_MERGEABLE + Joiner.on(", ").join(
Iterables.transform(unmergeable,
new Function<ChangeData, String>() {
@Override
public String apply(ChangeData cd) {
return String.valueOf(cd.getId().get());
}
}));
}
} catch (ResourceConflictException e) {
return BLOCKED_SUBMIT_TOOLTIP;
} catch (NoSuchChangeException | OrmException | IOException e) {
log.error("Error checking if change is submittable", e);
throw new OrmRuntimeException("Could not determine problems for the change", e);
}
return null;
}
/**
* Check if there are any problems with the given change. It doesn't take
* any problems of related changes into account.
* <p>
* @param cd the change to check for submittability
* @return if the change has any problems for submission
*/
public boolean submittable(ChangeData cd) {
try {
MergeOp.checkSubmitRule(cd);
return true;
} catch (ResourceConflictException | OrmException e) {
return false;
}
}
@Override
public UiAction.Description getDescription(RevisionResource resource) {
PatchSet.Id current = resource.getChange().currentPatchSetId();
String topic = resource.getChange().getTopic();
boolean visible = !resource.getPatchSet().isDraft()
&& resource.getChange().getStatus().isOpen()
&& resource.getPatchSet().getId().equals(current)
&& resource.getControl().canSubmit();
ReviewDb db = dbProvider.get();
ChangeData cd = changeDataFactory.create(db, resource.getControl());
try {
MergeOp.checkSubmitRule(cd);
} catch (ResourceConflictException e) {
visible = false;
} catch (OrmException e) {
log.error("Error checking if change is submittable", e);
throw new OrmRuntimeException("Could not determine problems for the change", e);
}
if (!visible) {
return new UiAction.Description()
.setLabel("")
.setTitle("")
.setVisible(false);
}
ChangeSet cs;
try {
cs = mergeSuperSet.completeChangeSet(db, cd.change());
} catch (OrmException | IOException e) {
throw new OrmRuntimeException("Could not determine complete set of " +
"changes to be submitted", e);
}
int topicSize = 0;
if (!Strings.isNullOrEmpty(topic)) {
topicSize = getChangesByTopic(topic).size();
}
boolean treatWithTopic = submitWholeTopic
&& !Strings.isNullOrEmpty(topic)
&& topicSize > 1;
String submitProblems =
problemsForSubmittingChangeset(cd, cs, resource.getUser());
Boolean enabled;
try {
// Recheck mergeability rather than using value stored in the index,
// which may be stale.
// TODO(dborowitz): This is ugly; consider providing a way to not read
// stored fields from the index in the first place.
// cd.setMergeable(null);
// That was done in unmergeableChanges which was called by
// problemsForSubmittingChangeset, so now it is safe to read from
// the cache, as it yields the same result.
enabled = cd.isMergeable();
} catch (OrmException e) {
throw new OrmRuntimeException("Could not determine mergeability", e);
}
if (submitProblems != null) {
return new UiAction.Description()
.setLabel(treatWithTopic
? submitTopicLabel : (cs.size() > 1)
? labelWithParents : label)
.setTitle(submitProblems)
.setVisible(true)
.setEnabled(false);
}
if (treatWithTopic) {
Map<String, String> params = ImmutableMap.of(
"topicSize", String.valueOf(topicSize),
"submitSize", String.valueOf(cs.size()));
return new UiAction.Description()
.setLabel(submitTopicLabel)
.setTitle(Strings.emptyToNull(
submitTopicTooltip.replace(params)))
.setVisible(true)
.setEnabled(Boolean.TRUE.equals(enabled));
} else {
RevId revId = resource.getPatchSet().getRevision();
Map<String, String> params = ImmutableMap.of(
"patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
"branch", resource.getChange().getDest().getShortName(),
"commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
"submitSize", String.valueOf(cs.size()));
ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors :
titlePattern;
return new UiAction.Description()
.setLabel(cs.size() > 1 ? labelWithParents : label)
.setTitle(Strings.emptyToNull(tp.replace(params)))
.setVisible(true)
.setEnabled(Boolean.TRUE.equals(enabled));
}
}
/**
* If the merge was attempted and it failed the system usually writes a
* comment as a ChangeMessage and sets status to NEW. Find the relevant
* message and return it.
*/
public ChangeMessage getConflictMessage(RevisionResource rsrc)
throws OrmException {
return FluentIterable.from(cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(),
rsrc.getPatchSet().getId()))
.filter(new Predicate<ChangeMessage>() {
@Override
public boolean apply(ChangeMessage input) {
return input.getAuthor() == null;
}
})
.last()
.orNull();
}
static String status(Change change) {
return change != null ? change.getStatus().name().toLowerCase() : "deleted";
}
public Collection<ChangeData> unmergeableChanges(ChangeSet cs)
throws OrmException, IOException {
Set<ChangeData> mergeabilityMap = new HashSet<>();
for (ChangeData change : cs.changes()) {
mergeabilityMap.add(change);
}
Multimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
for (Branch.NameKey branch : cbb.keySet()) {
Collection<ChangeData> targetBranch = cbb.get(branch);
HashMap<Change.Id, RevCommit> commits =
findCommits(targetBranch, branch.getParentKey());
Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
for (RevCommit commit : commits.values()) {
for (RevCommit parent : commit.getParents()) {
allParents.add(parent.getId());
}
}
for (ChangeData change : targetBranch) {
RevCommit commit = commits.get(change.getId());
boolean isMergeCommit = commit.getParentCount() > 1;
boolean isLastInChain = !allParents.contains(commit.getId());
// Recheck mergeability rather than using value stored in the index,
// which may be stale.
// TODO(dborowitz): This is ugly; consider providing a way to not read
// stored fields from the index in the first place.
change.setMergeable(null);
Boolean mergeable = change.isMergeable();
if (mergeable == null) {
// Skip whole check, cannot determine if mergeable
return null;
}
if (mergeable) {
mergeabilityMap.remove(change);
}
if (isLastInChain && isMergeCommit && mergeable) {
for (ChangeData c : targetBranch) {
mergeabilityMap.remove(c);
}
break;
}
}
}
return mergeabilityMap;
}
private HashMap<Change.Id, RevCommit> findCommits(
Collection<ChangeData> changes, Project.NameKey project)
throws IOException, OrmException {
HashMap<Change.Id, RevCommit> commits = new HashMap<>();
if (!changes.isEmpty()) {
try (Repository repo = repoManager.openRepository(project);
RevWalk walk = new RevWalk(repo)) {
for (ChangeData change : changes) {
PatchSet patchSet = dbProvider.get().patchSets()
.get(change.change().currentPatchSetId());
String commitId = patchSet.getRevision().get();
RevCommit commit = walk.parseCommit(ObjectId.fromString(commitId));
commits.put(change.getId(), commit);
}
}
}
return commits;
}
private RevisionResource onBehalfOf(RevisionResource rsrc, SubmitInput in)
throws AuthException, UnprocessableEntityException, OrmException {
ChangeControl caller = rsrc.getControl();
if (!caller.canSubmit()) {
throw new AuthException("submit not permitted");
}
if (!caller.canSubmitAs()) {
throw new AuthException("submit on behalf of not permitted");
}
IdentifiedUser targetUser = accounts.parseId(in.onBehalfOf);
if (targetUser == null) {
throw new UnprocessableEntityException(String.format(
"Account Not Found: %s", in.onBehalfOf));
}
ChangeControl target = caller.forUser(targetUser);
if (!target.getRefControl().isVisible()) {
throw new UnprocessableEntityException(String.format(
"on_behalf_of account %s cannot see destination ref",
targetUser.getAccountId()));
}
return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
}
public static boolean wholeTopicEnabled(Config config) {
return config.getBoolean("change", null, "submitWholeTopic" , false);
}
private List<ChangeData> getChangesByTopic(String topic) {
try {
return queryProvider.get().byTopicOpen(topic);
} catch (OrmException e) {
throw new OrmRuntimeException(e);
}
}
public static class CurrentRevision implements
RestModifyView<ChangeResource, SubmitInput> {
private final Provider<ReviewDb> dbProvider;
private final Submit submit;
private final ChangeJson.Factory json;
@Inject
CurrentRevision(Provider<ReviewDb> dbProvider,
Submit submit,
ChangeJson.Factory json) {
this.dbProvider = dbProvider;
this.submit = submit;
this.json = json;
}
@Override
public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
throws AuthException, ResourceConflictException,
RepositoryNotFoundException, IOException, OrmException,
UnprocessableEntityException {
PatchSet ps = dbProvider.get().patchSets()
.get(rsrc.getChange().currentPatchSetId());
if (ps == null) {
throw new ResourceConflictException("current revision is missing");
} else if (!rsrc.getControl().isPatchVisible(ps, dbProvider.get())) {
throw new AuthException("current revision not accessible");
}
Output out = submit.apply(new RevisionResource(rsrc, ps), input);
return json.create(ChangeJson.NO_OPTIONS).format(out.change);
}
}
}