All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.change.Submit Maven / Gradle / Ivy

There is a newer version: 3.11.1
Show newest version
// 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.annotations.VisibleForTesting;
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.RestApiException;
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.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
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.notedb.ChangeNotes;
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,
    UiAction {
  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;
    }
  }

  /**
   * Subclass of {@link SubmitInput} with special bits that may be flipped for
   * testing purposes only.
   */
  @VisibleForTesting
  public static class TestSubmitInput extends SubmitInput {
    public final boolean failAfterRefUpdates;

    public TestSubmitInput(SubmitInput base, boolean failAfterRefUpdates) {
      this.onBehalfOf = base.onBehalfOf;
      this.notify = base.notify;
      this.failAfterRefUpdates = failAfterRefUpdates;
    }
  }

  private final Provider dbProvider;
  private final GitRepositoryManager repoManager;
  private final ChangeData.Factory changeDataFactory;
  private final ChangeMessagesUtil cmUtil;
  private final ChangeNotes.Factory changeNotesFactory;
  private final Provider 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 queryProvider;
  private final PatchSetUtil psUtil;

  @Inject
  Submit(Provider dbProvider,
      GitRepositoryManager repoManager,
      ChangeData.Factory changeDataFactory,
      ChangeMessagesUtil cmUtil,
      ChangeNotes.Factory changeNotesFactory,
      Provider mergeOpProvider,
      MergeSuperSet mergeSuperSet,
      AccountsCollection accounts,
      ChangesCollection changes,
      @GerritServerConfig Config cfg,
      Provider queryProvider,
      PatchSetUtil psUtil) {
    this.dbProvider = dbProvider;
    this.repoManager = repoManager;
    this.changeDataFactory = changeDataFactory;
    this.cmUtil = cmUtil;
    this.changeNotesFactory = changeNotesFactory;
    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;
    this.psUtil = psUtil;
  }

  @Override
  public Output apply(RevisionResource rsrc, SubmitInput input)
      throws RestApiException, RepositoryNotFoundException, IOException,
      OrmException {
    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 (MergeOp op = mergeOpProvider.get()) {
      ReviewDb db = dbProvider.get();
      op.merge(db, change, caller, true, input);
      try {
        change = changeNotesFactory
            .createChecked(db, change.getProject(), change.getId()).getChange();
      } catch (NoSuchChangeException e) {
        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$
      case ABANDONED:
      case DRAFT:
      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 user 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,
      CurrentUser user) {
    try {
      @SuppressWarnings("resource")
      ReviewDb db = dbProvider.get();
      if (cs.furtherHiddenChanges()) {
        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
      }
      for (ChangeData c : cs.changes()) {
        ChangeControl changeControl = c.changeControl(user);

        if (!changeControl.isVisible(db)) {
          return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
        }
        if (!changeControl.canSubmit()) {
          return BLOCKED_SUBMIT_TOOLTIP;
        }
        MergeOp.checkSubmitRule(c);
      }

      Collection 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() {
              @Override
              public String apply(ChangeData cd) {
                return String.valueOf(cd.getId().get());
              }
            }));
      }
    } catch (ResourceConflictException e) {
      return BLOCKED_SUBMIT_TOOLTIP;
    } catch (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.
   * 

* @param cd the change to check for submittability * @return if the change has any problems for submission */ public static 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(), resource.getControl().getUser()); } 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 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)); } RevId revId = resource.getPatchSet().getRevision(); Map 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() { @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 unmergeableChanges(ChangeSet cs) throws OrmException, IOException { Set mergeabilityMap = new HashSet<>(); for (ChangeData change : cs.changes()) { mergeabilityMap.add(change); } Multimap cbb = cs.changesByBranch(); for (Branch.NameKey branch : cbb.keySet()) { Collection targetBranch = cbb.get(branch); HashMap commits = findCommits(targetBranch, branch.getParentKey()); Set 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 findCommits( Collection changes, Project.NameKey project) throws IOException, OrmException { HashMap commits = new HashMap<>(); try (Repository repo = repoManager.openRepository(project); RevWalk walk = new RevWalk(repo)) { for (ChangeData change : changes) { RevCommit commit = walk.parseCommit(ObjectId.fromString( psUtil.current(dbProvider.get(), change.notes()).getRevision().get())); 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 getChangesByTopic(String topic) { try { return queryProvider.get().byTopicOpen(topic); } catch (OrmException e) { throw new OrmRuntimeException(e); } } public static class CurrentRevision implements RestModifyView { private final Provider dbProvider; private final Submit submit; private final ChangeJson.Factory json; private final PatchSetUtil psUtil; @Inject CurrentRevision(Provider dbProvider, Submit submit, ChangeJson.Factory json, PatchSetUtil psUtil) { this.dbProvider = dbProvider; this.submit = submit; this.json = json; this.psUtil = psUtil; } @Override public ChangeInfo apply(ChangeResource rsrc, SubmitInput input) throws RestApiException, RepositoryNotFoundException, IOException, OrmException { PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes()); 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); } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy