| // Copyright (C) 2016 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.googlesource.gerrit.plugins.automerger; |
| |
| import static com.google.common.base.Strings.isNullOrEmpty; |
| |
| import com.google.common.base.Joiner; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.extensions.api.GerritApi; |
| import com.google.gerrit.extensions.api.changes.AbandonInput; |
| import com.google.gerrit.extensions.api.changes.ChangeApi; |
| import com.google.gerrit.extensions.api.changes.NotifyHandling; |
| import com.google.gerrit.extensions.api.changes.RestoreInput; |
| import com.google.gerrit.extensions.api.changes.ReviewInput; |
| import com.google.gerrit.extensions.client.ChangeStatus; |
| import com.google.gerrit.extensions.client.ListChangesOption; |
| import com.google.gerrit.extensions.common.ChangeInfo; |
| import com.google.gerrit.extensions.common.ChangeInput; |
| import com.google.gerrit.extensions.common.CommitInfo; |
| import com.google.gerrit.extensions.common.LabelInfo; |
| import com.google.gerrit.extensions.common.MergeInput; |
| import com.google.gerrit.extensions.common.MergePatchSetInput; |
| import com.google.gerrit.extensions.common.RevisionInfo; |
| import com.google.gerrit.extensions.events.ChangeAbandonedListener; |
| import com.google.gerrit.extensions.events.ChangeRestoredListener; |
| import com.google.gerrit.extensions.events.CommentAddedListener; |
| import com.google.gerrit.extensions.events.RevisionCreatedListener; |
| import com.google.gerrit.extensions.events.TopicEditedListener; |
| import com.google.gerrit.extensions.restapi.AuthException; |
| import com.google.gerrit.extensions.restapi.MergeConflictException; |
| import com.google.gerrit.extensions.restapi.RestApiException; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.util.ManualRequestContext; |
| import com.google.gerrit.server.util.OneOffRequestContext; |
| import com.google.inject.Inject; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.EnumSet; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.OptionalInt; |
| import java.util.Set; |
| import java.util.TreeMap; |
| import java.util.UUID; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| /** |
| * DownstreamCreator will receive an event on an uploaded, published, or restored patchset, and |
| * upload a merge of the original patchset downstream, as determined by the configuration file. When |
| * a topic or vote is changed on a patchset, or a change is abandoned, all downstream patchsets will |
| * be modified as well. |
| */ |
| public class DownstreamCreator |
| implements ChangeAbandonedListener, |
| ChangeRestoredListener, |
| CommentAddedListener, |
| RevisionCreatedListener, |
| TopicEditedListener { |
| private static final Logger log = LoggerFactory.getLogger(DownstreamCreator.class); |
| private static final String AUTOMERGER_TAG = "autogenerated:Automerger"; |
| private static final String MERGE_CONFLICT_TAG = "autogenerated:MergeConflict"; |
| private static final String SUBJECT_PREFIX = "automerger"; |
| private static final String SKIPPED_PREFIX = "skipped"; |
| private static final String CURRENT = "current"; |
| |
| protected GerritApi gApi; |
| protected ConfigLoader config; |
| protected CurrentUser user; |
| |
| private final OneOffRequestContext oneOffRequestContext; |
| |
| @Inject |
| public DownstreamCreator( |
| GerritApi gApi, ConfigLoader config, OneOffRequestContext oneOffRequestContext) { |
| this.gApi = gApi; |
| this.config = config; |
| this.oneOffRequestContext = oneOffRequestContext; |
| } |
| |
| /** |
| * Abandons downstream changes if a change is abandoned. |
| * |
| * @param event Event we are listening to. |
| */ |
| @Override |
| public void onChangeAbandoned(ChangeAbandonedListener.Event event) { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| ChangeInfo change = event.getChange(); |
| String revision = |
| gApi.changes() |
| .id(change._number) |
| .revision(event.getRevision()._number) |
| .commit(false) |
| .commit; |
| log.debug("Detected revision {} abandoned on {}.", revision, change.project); |
| abandonDownstream(change, revision); |
| } catch (ConfigInvalidException | StorageException | RestApiException e) { |
| log.error("Automerger plugin failed onChangeAbandoned for {}", event.getChange().id, e); |
| } |
| } |
| |
| /** |
| * Updates downstream topics if a change has its topic modified. |
| * |
| * @param event Event we are listening to. |
| */ |
| @Override |
| public void onTopicEdited(TopicEditedListener.Event event) { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| ChangeInfo eventChange = event.getChange(); |
| // We have to re-query for this in order to include the current revision |
| ChangeInfo change; |
| try { |
| change = |
| gApi.changes() |
| .id(eventChange._number) |
| .get(EnumSet.of(ListChangesOption.CURRENT_REVISION)); |
| } catch (RestApiException e) { |
| log.error("Automerger could not get change with current revision for onTopicEdited: ", e); |
| return; |
| } |
| String oldTopic = event.getOldTopic(); |
| String revision = change.currentRevision; |
| Set<String> downstreamBranches; |
| try { |
| downstreamBranches = config.getDownstreamBranches(change.branch, change.project); |
| } catch (RestApiException | IOException | ConfigInvalidException e) { |
| log.error("Failed to edit downstream topics of {}", change.id, e); |
| return; |
| } |
| |
| if (downstreamBranches.isEmpty()) { |
| log.debug("Downstream branches of {} on {} are empty", change.branch, change.project); |
| return; |
| } |
| |
| // If change is empty, prevent someone breaking topic. |
| if (isNullOrEmpty(change.topic)) { |
| try { |
| gApi.changes().id(change._number).topic(oldTopic); |
| ReviewInput reviewInput = new ReviewInput(); |
| reviewInput.message( |
| "Automerger prevented the topic from changing. Topic can only be modified on " |
| + "non-automerger-created CLs to a non-empty value."); |
| reviewInput.notify = NotifyHandling.NONE; |
| gApi.changes().id(change._number).revision(CURRENT).review(reviewInput); |
| } catch (RestApiException e) { |
| log.error("Failed to prevent setting empty topic for automerger plugin.", e); |
| } |
| } else { |
| for (String downstreamBranch : downstreamBranches) { |
| try { |
| List<Integer> existingDownstream = |
| getExistingMergesOnBranch(revision, oldTopic, downstreamBranch); |
| for (Integer changeNumber : existingDownstream) { |
| log.debug("Setting topic {} on {}", change.topic, changeNumber); |
| gApi.changes().id(changeNumber).topic(change.topic); |
| } |
| } catch (RestApiException | InvalidQueryParameterException e) { |
| log.error("Failed to edit downstream topics of {}", change.id, e); |
| } |
| } |
| } |
| } catch (StorageException | ConfigInvalidException e) { |
| log.error("Automerger plugin failed onTopicEdited for {}", event.getChange().id, e); |
| } |
| } |
| |
| /** |
| * Updates downstream votes for a change each time a comment is made. |
| * |
| * @param event Event we are listening to. |
| */ |
| @Override |
| public void onCommentAdded(CommentAddedListener.Event event) { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| RevisionInfo eventRevision = event.getRevision(); |
| if (!eventRevision.isCurrent) { |
| log.info( |
| "Not updating downstream votes since revision {} is not current.", |
| eventRevision._number); |
| return; |
| } |
| ChangeInfo change = event.getChange(); |
| String revision = gApi.changes().id(event.getChange()._number).current().commit(false).commit; |
| Set<String> downstreamBranches; |
| downstreamBranches = config.getDownstreamBranches(change.branch, change.project); |
| |
| if (downstreamBranches.isEmpty()) { |
| log.debug("Downstream branches of {} on {} are empty", change.branch, change.project); |
| return; |
| } |
| |
| Map<String, LabelInfo> labels = |
| gApi.changes() |
| .id(change._number) |
| .get(EnumSet.of(ListChangesOption.DETAILED_LABELS)) |
| .labels; |
| |
| for (String downstreamBranch : downstreamBranches) { |
| try { |
| List<Integer> existingDownstream = |
| getExistingMergesOnBranch(revision, change.topic, downstreamBranch); |
| for (Integer changeNumber : existingDownstream) { |
| ChangeInfo downstreamChange = |
| gApi.changes().id(changeNumber).get(EnumSet.of(ListChangesOption.CURRENT_REVISION)); |
| for (Map.Entry<String, LabelInfo> labelEntry : labels.entrySet()) { |
| if (labelEntry.getValue().all != null && labelEntry.getValue().all.size() > 0) { |
| OptionalInt maxVote = |
| labelEntry.getValue().all.stream() |
| .filter(o -> o.value != null) |
| .mapToInt(i -> i.value) |
| .max(); |
| |
| if (maxVote.isPresent()) { |
| updateVote(downstreamChange, labelEntry.getKey(), (short) maxVote.getAsInt()); |
| } |
| } |
| } |
| } |
| } catch (RestApiException | InvalidQueryParameterException e) { |
| log.error("Exception when updating downstream votes of {}", change.id, e); |
| } |
| } |
| } catch (StorageException | ConfigInvalidException | RestApiException | IOException e) { |
| log.error("Automerger plugin failed onCommentAdded for {}", event.getChange().id, e); |
| } |
| } |
| |
| /** |
| * Automerges changes downstream if a change is restored. |
| * |
| * @param event Event we are listening to. |
| */ |
| @Override |
| public void onChangeRestored(ChangeRestoredListener.Event event) { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| ChangeInfo change = event.getChange(); |
| automergeChanges(change, event.getRevision()); |
| } catch (RestApiException |
| | IOException |
| | ConfigInvalidException |
| | InvalidQueryParameterException |
| | StorageException e) { |
| log.error("Automerger plugin failed onChangeRestored for {}", event.getChange().id, e); |
| } |
| } |
| |
| /** |
| * Automerges changes downstream if a revision is created. |
| * |
| * @param event Event we are listening to. |
| */ |
| @Override |
| public void onRevisionCreated(RevisionCreatedListener.Event event) { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| ChangeInfo change = event.getChange(); |
| automergeChanges(change, event.getRevision()); |
| } catch (RestApiException |
| | IOException |
| | ConfigInvalidException |
| | InvalidQueryParameterException |
| | StorageException e) { |
| log.error("Automerger plugin failed onRevisionCreated for {}", event.getChange().id, e); |
| } |
| } |
| |
| public String getOrSetTopic(int sourceId, String topic) |
| throws RestApiException, ConfigInvalidException { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| if (isNullOrEmpty(topic)) { |
| topic = "am-" + UUID.randomUUID(); |
| log.debug("Setting original change {} topic to {}", sourceId, topic); |
| gApi.changes().id(sourceId).topic(topic); |
| } |
| return topic; |
| } |
| } |
| |
| /** |
| * Creates merges downstream, and votes on the automerge label if we have a failed merge. |
| * |
| * @param mdsMergeInput Input containing the downstream branch map and source change ID. |
| * @throws RestApiException Throws if we fail a REST API call. |
| * @throws ConfigInvalidException Throws if we get a malformed configuration |
| * @throws InvalidQueryParameterException Throws if we attempt to add an invalid value to query. |
| * @throws StorageException Throws if we fail to open the request context |
| */ |
| public void createMergesAndHandleConflicts(MultipleDownstreamMergeInput mdsMergeInput) |
| throws RestApiException, ConfigInvalidException, InvalidQueryParameterException, |
| StorageException { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| ReviewInput reviewInput = new ReviewInput(); |
| Map<String, Short> labels = new HashMap<>(); |
| try { |
| createDownstreamMerges(mdsMergeInput); |
| |
| reviewInput.message = |
| "Automerging change " |
| + mdsMergeInput.changeNumber |
| + " to " |
| + Joiner.on(", ").join(mdsMergeInput.dsBranchMap.keySet()) |
| + " succeeded!"; |
| reviewInput.notify = NotifyHandling.NONE; |
| } catch (FailedMergeException e) { |
| reviewInput.message = e.getDisplayString(); |
| reviewInput.notify = NotifyHandling.ALL; |
| reviewInput.tag = MERGE_CONFLICT_TAG; |
| // Vote minAutomergeVote if we hit a conflict. |
| if (!config.minAutomergeVoteDisabled()) { |
| labels.put(config.getAutomergeLabel(), config.getMinAutomergeVote()); |
| } |
| } |
| reviewInput.labels = labels; |
| |
| // Make the vote on the original change |
| ChangeInfo originalChange = |
| getOriginalChange(mdsMergeInput.changeNumber, mdsMergeInput.currentRevision); |
| // if this fails, i.e. -2 is restricted, catch it and still post message without a vote. |
| try { |
| gApi.changes().id(originalChange._number).revision(CURRENT).review(reviewInput); |
| } catch (AuthException e) { |
| reviewInput.labels = null; |
| gApi.changes().id(originalChange._number).revision(CURRENT).review(reviewInput); |
| } |
| } |
| } |
| |
| /** |
| * Creates merge downstream. |
| * |
| * @param mdsMergeInput Input containing the downstream branch map and source change ID. |
| * @throws RestApiException Throws if we fail a REST API call. |
| * @throws FailedMergeException Throws if we get a merge conflict when merging downstream. |
| * @throws ConfigInvalidException Throws if we get a malformed config file |
| * @throws InvalidQueryParameterException Throws if we attempt to add an invalid value to query. |
| * @throws StorageException Throws if we fail to open the request context |
| */ |
| private void createDownstreamMerges(MultipleDownstreamMergeInput mdsMergeInput) |
| throws RestApiException, FailedMergeException, ConfigInvalidException, |
| InvalidQueryParameterException, StorageException { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| // Map from branch to error message |
| Map<String, String> failedMergeBranchMap = new TreeMap<>(); |
| |
| List<Integer> existingDownstream; |
| for (String downstreamBranch : mdsMergeInput.dsBranchMap.keySet()) { |
| // If there are existing downstream merges, update them |
| // Otherwise, create them. |
| boolean createDownstreams = true; |
| if (mdsMergeInput.obsoleteRevision != null) { |
| existingDownstream = |
| getExistingMergesOnBranch( |
| mdsMergeInput.obsoleteRevision, mdsMergeInput.topic, downstreamBranch); |
| if (!existingDownstream.isEmpty()) { |
| log.debug( |
| "Attempting to update downstream merge of {} on branch {}", |
| mdsMergeInput.currentRevision, |
| downstreamBranch); |
| // existingDownstream should almost always be of length one, but |
| // it's possible to construct it so that it's not |
| for (Integer dsChangeNumber : existingDownstream) { |
| try { |
| updateDownstreamMerge( |
| mdsMergeInput.currentRevision, |
| mdsMergeInput.subject, |
| dsChangeNumber, |
| mdsMergeInput.dsBranchMap.get(downstreamBranch), |
| mdsMergeInput.changeNumber, |
| downstreamBranch); |
| createDownstreams = false; |
| } catch (MergeConflictException e) { |
| failedMergeBranchMap.put(downstreamBranch, e.getMessage()); |
| log.debug( |
| "Abandoning existing, obsolete {} due to merge conflict.", dsChangeNumber); |
| abandonChange(dsChangeNumber); |
| } |
| } |
| } |
| } |
| if (createDownstreams) { |
| log.debug( |
| "Attempting to create downstream merge of {} on branch {}", |
| mdsMergeInput.currentRevision, |
| downstreamBranch); |
| SingleDownstreamMergeInput sdsMergeInput = new SingleDownstreamMergeInput(); |
| sdsMergeInput.currentRevision = mdsMergeInput.currentRevision; |
| sdsMergeInput.changeNumber = mdsMergeInput.changeNumber; |
| sdsMergeInput.project = mdsMergeInput.project; |
| sdsMergeInput.topic = mdsMergeInput.topic; |
| sdsMergeInput.subject = mdsMergeInput.subject; |
| sdsMergeInput.downstreamBranch = downstreamBranch; |
| sdsMergeInput.doMerge = mdsMergeInput.dsBranchMap.get(downstreamBranch); |
| try { |
| createSingleDownstreamMerge(sdsMergeInput); |
| } catch (MergeConflictException e) { |
| failedMergeBranchMap.put(downstreamBranch, e.getMessage()); |
| } |
| } |
| } |
| |
| if (!failedMergeBranchMap.isEmpty()) { |
| String conflictMessage = config.getConflictMessage(); |
| if (mdsMergeInput.project.equals(config.getManifestProject())) { |
| conflictMessage = config.getManifestConflictMessage(); |
| } |
| throw new FailedMergeException( |
| failedMergeBranchMap, |
| mdsMergeInput.currentRevision, |
| config.getHostName(), |
| mdsMergeInput.project, |
| mdsMergeInput.changeNumber, |
| mdsMergeInput.patchsetNumber, |
| conflictMessage, |
| mdsMergeInput.topic); |
| } |
| } |
| } |
| |
| /** |
| * Get change IDs of the immediately downstream changes of the revision on the branch. |
| * |
| * @param upstreamRevision Revision of the original change. |
| * @param topic Topic of the original change. |
| * @param downstreamBranch Branch to check for existing merge CLs. |
| * @return List of change numbers that are downstream of the given branch. |
| * @throws RestApiException Throws when we fail a REST API call. |
| * @throws InvalidQueryParameterException Throws when we try to add an invalid value to the query. |
| * @throws ConfigInvalidException Throws if we fail to read the config |
| * @throws StorageException Throws if we fail to open the request context |
| */ |
| private List<Integer> getExistingMergesOnBranch( |
| String upstreamRevision, String topic, String downstreamBranch) |
| throws RestApiException, InvalidQueryParameterException, StorageException, |
| ConfigInvalidException { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| List<Integer> downstreamChangeNumbers = new ArrayList<>(); |
| List<ChangeInfo> changes = getChangesInTopicAndBranch(topic, downstreamBranch); |
| |
| for (ChangeInfo change : changes) { |
| String changeRevision = change.currentRevision; |
| RevisionInfo revision = change.revisions.get(changeRevision); |
| List<CommitInfo> parents = revision.commit.parents; |
| if (parents.size() > 1) { |
| String secondParent = parents.get(1).commit; |
| if (secondParent.equals(upstreamRevision)) { |
| downstreamChangeNumbers.add(change._number); |
| } |
| } |
| } |
| return downstreamChangeNumbers; |
| } |
| } |
| |
| /** |
| * Create a single downstream merge. |
| * |
| * @param sdsMergeInput Input containing metadata for the merge. |
| * @throws RestApiException |
| * @throws ConfigInvalidException |
| * @throws InvalidQueryParameterException |
| * @throws StorageException |
| */ |
| private void createSingleDownstreamMerge(SingleDownstreamMergeInput sdsMergeInput) |
| throws RestApiException, ConfigInvalidException, InvalidQueryParameterException, |
| StorageException { |
| try (ManualRequestContext ctx = oneOffRequestContext.openAs(config.getContextUserId())) { |
| String currentTopic = getOrSetTopic(sdsMergeInput.changeNumber, sdsMergeInput.topic); |
| |
| if (isAlreadyMerged(sdsMergeInput, currentTopic)) { |
| log.info( |
| "Commit {} already merged into {}, not automerging again.", |
| sdsMergeInput.currentRevision, |
| sdsMergeInput.downstreamBranch); |
| return; |
| } |
| |
| MergeInput mergeInput = new MergeInput(); |
| mergeInput.source = sdsMergeInput.currentRevision; |
| mergeInput.strategy = "recursive"; |
| |
| log.debug("Creating downstream merge for {}", sdsMergeInput.currentRevision); |
| ChangeInput downstreamChangeInput = new ChangeInput(); |
| downstreamChangeInput.project = sdsMergeInput.project; |
| downstreamChangeInput.branch = sdsMergeInput.downstreamBranch; |
| downstreamChangeInput.subject = |
| getSubjectForDownstreamMerge(sdsMergeInput.subject, sdsMergeInput.currentRevision, false); |
| downstreamChangeInput.topic = currentTopic; |
| downstreamChangeInput.merge = mergeInput; |
| downstreamChangeInput.notify = NotifyHandling.NONE; |
| |
| downstreamChangeInput.baseChange = |
| getBaseChangeId( |
| getChangeParents(sdsMergeInput.changeNumber, sdsMergeInput.currentRevision), |
| sdsMergeInput.downstreamBranch); |
| |
| if (!sdsMergeInput.doMerge) { |
| mergeInput.strategy = "ours"; |
| downstreamChangeInput.subject = |
| getSubjectForDownstreamMerge( |
| sdsMergeInput.subject, sdsMergeInput.currentRevision, true); |
| log.debug( |
| "Skipping merge for {} to {}", |
| sdsMergeInput.currentRevision, |
| sdsMergeInput.downstreamBranch); |
| } |
| |
| ChangeApi downstreamChange = gApi.changes().create(downstreamChangeInput); |
| tagChange(downstreamChange.get(), "Automerger change created!"); |
| } |
| } |
| |
| /** |
| * Get the base change ID that the downstream change should be based off of, given the parents. |
| * |
| * <p>Given changes A and B where A is the first parent of B, and where A' is the change whose |
| * second parent is A, and B' is the change whose second parent is B, the first parent of B' |
| * should be A'. |
| * |
| * @param parents Parent commit SHAs of the change |
| * @return The base change ID that the change should be based off of, null if there is none. |
| * @throws InvalidQueryParameterException |
| * @throws RestApiException |
| */ |
| private String getBaseChangeId(List<String> parents, String branch) |
| throws InvalidQueryParameterException, RestApiException { |
| if (parents.isEmpty()) { |
| log.info("No base change id for change with no parents."); |
| return null; |
| } |
| // 1) Get topic of first parent |
| String firstParentTopic = getTopic(parents.get(0)); |
| if (firstParentTopic == null) { |
| return null; |
| } |
| // 2) query that topic and use that to find A' |
| List<ChangeInfo> changesInTopic = getChangesInTopicAndBranch(firstParentTopic, branch); |
| String firstParent = parents.get(0); |
| for (ChangeInfo change : changesInTopic) { |
| List<CommitInfo> topicChangeParents = |
| change.revisions.get(change.currentRevision).commit.parents; |
| if (topicChangeParents.size() > 1 && topicChangeParents.get(1).commit.equals(firstParent)) { |
| return String.valueOf(change._number); |
| } |
| } |
| return null; |
| } |
| |
| private void automergeChanges(ChangeInfo change, RevisionInfo revisionInfo) |
| throws RestApiException, IOException, ConfigInvalidException, InvalidQueryParameterException, |
| StorageException { |
| String currentRevision = |
| gApi.changes().id(change._number).revision(revisionInfo._number).commit(false).commit; |
| log.debug( |
| "Handling patchsetevent with change id {} and revision {}", change.id, currentRevision); |
| |
| Set<String> downstreamBranches = config.getDownstreamBranches(change.branch, change.project); |
| |
| if (downstreamBranches.isEmpty()) { |
| log.debug("Downstream branches of {} on {} are empty", change.branch, change.project); |
| return; |
| } |
| |
| // Map whether or not we should merge it or skip it for each downstream |
| Map<String, Boolean> dsBranchMap = new HashMap<>(); |
| for (String downstreamBranch : downstreamBranches) { |
| boolean isSkipMerge = config.isSkipMerge(change.branch, downstreamBranch, change.subject); |
| dsBranchMap.put(downstreamBranch, !isSkipMerge); |
| } |
| log.debug("Automerging change {} from branch {}", change.id, change.branch); |
| |
| ChangeApi currentChange = gApi.changes().id(change._number); |
| String previousRevision = getPreviousRevision(currentChange, revisionInfo._number); |
| |
| MultipleDownstreamMergeInput mdsMergeInput = new MultipleDownstreamMergeInput(); |
| mdsMergeInput.dsBranchMap = dsBranchMap; |
| mdsMergeInput.changeNumber = change._number; |
| mdsMergeInput.patchsetNumber = revisionInfo._number; |
| mdsMergeInput.project = change.project; |
| mdsMergeInput.topic = getOrSetTopic(change._number, change.topic); |
| mdsMergeInput.subject = change.subject; |
| mdsMergeInput.obsoleteRevision = previousRevision; |
| mdsMergeInput.currentRevision = currentRevision; |
| |
| createMergesAndHandleConflicts(mdsMergeInput); |
| } |
| |
| private void abandonDownstream(ChangeInfo change, String revision) |
| throws ConfigInvalidException, StorageException { |
| try { |
| Set<String> downstreamBranches = config.getDownstreamBranches(change.branch, change.project); |
| if (downstreamBranches.isEmpty()) { |
| log.debug("Downstream branches of {} on {} are empty", change.branch, change.project); |
| return; |
| } |
| |
| for (String downstreamBranch : downstreamBranches) { |
| List<Integer> existingDownstream = |
| getExistingMergesOnBranch(revision, change.topic, downstreamBranch); |
| log.debug("Abandoning existing downstreams: {}", existingDownstream); |
| for (Integer changeNumber : existingDownstream) { |
| abandonChange(changeNumber); |
| } |
| } |
| } catch (RestApiException | IOException | InvalidQueryParameterException e) { |
| log.error("Failed to abandon downstreams of {}", change.id, e); |
| } |
| } |
| |
| private void updateVote(ChangeInfo change, String label, short vote) throws RestApiException { |
| log.debug("Giving {} for label {} to {}", vote, label, change.id); |
| // Vote on all downstream branches unless merge conflict. |
| ReviewInput reviewInput = new ReviewInput(); |
| Map<String, Short> labels = new HashMap<>(); |
| labels.put(label, vote); |
| reviewInput.labels = labels; |
| reviewInput.notify = NotifyHandling.NONE; |
| reviewInput.tag = AUTOMERGER_TAG; |
| try { |
| gApi.changes().id(change.id).revision(CURRENT).review(reviewInput); |
| } catch (AuthException e) { |
| log.error("Automerger could not set label, but still continuing.", e); |
| } |
| } |
| |
| private void tagChange(ChangeInfo change, String message) throws RestApiException { |
| ReviewInput reviewInput = new ReviewInput(); |
| reviewInput.message(message); |
| reviewInput.notify = NotifyHandling.NONE; |
| reviewInput.tag = AUTOMERGER_TAG; |
| try { |
| gApi.changes().id(change.id).revision(CURRENT).review(reviewInput); |
| } catch (AuthException e) { |
| log.error("Automerger could not set label, but still continuing.", e); |
| } |
| } |
| |
| private void updateDownstreamMerge( |
| String newParentRevision, |
| String upstreamSubject, |
| Integer sourceNum, |
| boolean doMerge, |
| Integer upstreamChangeNumber, |
| String downstreamBranch) |
| throws RestApiException, InvalidQueryParameterException { |
| MergeInput mergeInput = new MergeInput(); |
| mergeInput.source = newParentRevision; |
| |
| MergePatchSetInput mergePatchSetInput = new MergePatchSetInput(); |
| |
| mergePatchSetInput.subject = |
| getSubjectForDownstreamMerge(upstreamSubject, newParentRevision, false); |
| if (!doMerge) { |
| mergeInput.strategy = "ours"; |
| mergePatchSetInput.subject = |
| getSubjectForDownstreamMerge(upstreamSubject, newParentRevision, true); |
| log.debug("Skipping merge for {} on {}", newParentRevision, sourceNum); |
| } |
| mergePatchSetInput.merge = mergeInput; |
| |
| mergePatchSetInput.baseChange = |
| getBaseChangeId( |
| getChangeParents(upstreamChangeNumber, newParentRevision), downstreamBranch); |
| |
| ChangeApi originalChange = gApi.changes().id(sourceNum); |
| |
| if (originalChange.info().status == ChangeStatus.ABANDONED) { |
| RestoreInput restoreInput = new RestoreInput(); |
| restoreInput.message = "Restoring change due to upstream automerge."; |
| originalChange.restore(restoreInput); |
| } |
| |
| originalChange.createMergePatchSet(mergePatchSetInput); |
| } |
| |
| private String getPreviousRevision(ChangeApi change, int currentPatchSetNumber) |
| throws RestApiException { |
| String previousRevision = null; |
| int maxPatchSetNum = 0; |
| if (currentPatchSetNumber > 1) { |
| // Get sha of patch set with highest number we can see |
| Map<String, RevisionInfo> revisionMap = |
| change.get(EnumSet.of(ListChangesOption.ALL_REVISIONS)).revisions; |
| for (Map.Entry<String, RevisionInfo> revisionEntry : revisionMap.entrySet()) { |
| int revisionPatchNumber = revisionEntry.getValue()._number; |
| if (revisionPatchNumber > maxPatchSetNum && revisionPatchNumber < currentPatchSetNumber) { |
| previousRevision = revisionEntry.getKey(); |
| maxPatchSetNum = revisionPatchNumber; |
| } |
| } |
| } |
| return previousRevision; |
| } |
| |
| private ChangeInfo getOriginalChange(int changeNumber, String currentRevision) |
| throws RestApiException, InvalidQueryParameterException { |
| List<String> parents = getChangeParents(changeNumber, currentRevision); |
| if (parents.size() >= 2) { |
| String secondParentRevision = parents.get(1); |
| String topic = gApi.changes().id(changeNumber).topic(); |
| List<ChangeInfo> changesInTopic = getChangesInTopic(topic); |
| for (ChangeInfo change : changesInTopic) { |
| if (change.currentRevision.equals(secondParentRevision)) { |
| return getOriginalChange(change._number, secondParentRevision); |
| } |
| } |
| } |
| return gApi.changes().id(changeNumber).get(); |
| } |
| |
| private List<String> getChangeParents(int changeNumber, String currentRevision) |
| throws RestApiException { |
| ChangeApi change = gApi.changes().id(changeNumber); |
| List<String> parents = new ArrayList<>(); |
| Map<String, RevisionInfo> revisionMap = |
| change.get(EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)) |
| .revisions; |
| List<CommitInfo> changeParents = revisionMap.get(currentRevision).commit.parents; |
| for (CommitInfo commit : changeParents) { |
| parents.add(commit.commit); |
| } |
| return parents; |
| } |
| |
| private void abandonChange(Integer changeNumber) throws RestApiException { |
| log.debug("Abandoning change: {}", changeNumber); |
| AbandonInput abandonInput = new AbandonInput(); |
| abandonInput.notify = NotifyHandling.NONE; |
| abandonInput.message = "Merge parent updated; abandoning due to upstream conflict."; |
| gApi.changes().id(changeNumber).abandon(abandonInput); |
| } |
| |
| private String getTopic(String revision) throws InvalidQueryParameterException, RestApiException { |
| QueryBuilder queryBuilder = new QueryBuilder(); |
| queryBuilder.addParameter("commit", revision); |
| List<ChangeInfo> changes = |
| gApi.changes() |
| .query(queryBuilder.get()) |
| .withOption(ListChangesOption.CURRENT_REVISION) |
| .get(); |
| if (!changes.isEmpty()) { |
| for (ChangeInfo change : changes) { |
| if (change.currentRevision.equals(revision) && !"".equals(change.topic)) { |
| return change.topic; |
| } |
| } |
| } |
| return null; |
| } |
| |
| private QueryBuilder constructTopicQuery(String topic) throws InvalidQueryParameterException { |
| QueryBuilder queryBuilder = new QueryBuilder(); |
| queryBuilder.addParameter("topic", topic); |
| queryBuilder.addParameter("status", "open"); |
| return queryBuilder; |
| } |
| |
| private List<ChangeInfo> getChangesInTopic(String topic) |
| throws InvalidQueryParameterException, RestApiException { |
| QueryBuilder queryBuilder = constructTopicQuery(topic); |
| return gApi.changes() |
| .query(queryBuilder.get()) |
| .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT) |
| .get(); |
| } |
| |
| private List<ChangeInfo> getChangesInTopicAndBranch(String topic, String downstreamBranch) |
| throws InvalidQueryParameterException, RestApiException { |
| QueryBuilder queryBuilder = constructTopicQuery(topic); |
| queryBuilder.addParameter("branch", downstreamBranch); |
| return gApi.changes() |
| .query(queryBuilder.get()) |
| .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT) |
| .get(); |
| } |
| |
| private boolean isAlreadyMerged(SingleDownstreamMergeInput sdsMergeInput, String currentTopic) |
| throws InvalidQueryParameterException, RestApiException { |
| // If we've already merged this commit to this branch, don't do it again. |
| List<ChangeInfo> changes = |
| getChangesInTopicAndBranch(currentTopic, sdsMergeInput.downstreamBranch); |
| for (ChangeInfo change : changes) { |
| if (change.branch.equals(sdsMergeInput.downstreamBranch)) { |
| List<CommitInfo> parents = change.revisions.get(change.currentRevision).commit.parents; |
| if (parents.size() > 1) { |
| String secondParent = parents.get(1).commit; |
| if (secondParent.equals(sdsMergeInput.currentRevision)) { |
| return true; |
| } |
| } |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Create subject line for downstream merge with metadata from upstream change. |
| * |
| * <p>The downstream subject will be in the format: "[automerger] upstreamSubject am: |
| * upstreamRevision". If it is a skip, "am" will be replaced with "skipped", and [automerger] |
| * replaced with [automerger skipped]. |
| * |
| * @param upstreamSubject Subject line of the upstream change |
| * @param upstreamRevision Commit SHA1 of the upstream change |
| * @param skipped Whether or not the merge is done with "-s ours" |
| * @return Subject line for downstream merge |
| */ |
| private String getSubjectForDownstreamMerge( |
| String upstreamSubject, String upstreamRevision, boolean skipped) { |
| if (!upstreamSubject.startsWith("[" + SUBJECT_PREFIX)) { |
| String prefix = "[" + SUBJECT_PREFIX + "]"; |
| if (skipped) { |
| prefix = "[" + SUBJECT_PREFIX + " " + SKIPPED_PREFIX + "]"; |
| } |
| upstreamSubject = Joiner.on(" ").join(prefix, upstreamSubject); |
| } |
| String denotationString = skipped ? "skipped:" : "am:"; |
| return Joiner.on(" ") |
| .join(upstreamSubject, denotationString, upstreamRevision.substring(0, 10)); |
| } |
| } |