blob: a06741647297df2b921117d1f073632aba060e9b [file] [log] [blame]
// 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 com.google.common.base.Joiner;
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.ApprovalInfo;
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.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.ChangeMergedListener;
import com.google.gerrit.extensions.events.ChangeRestoredListener;
import com.google.gerrit.extensions.events.CommentAddedListener;
import com.google.gerrit.extensions.events.DraftPublishedListener;
import com.google.gerrit.extensions.events.RevisionCreatedListener;
import com.google.gerrit.extensions.events.TopicEditedListener;
import com.google.gerrit.extensions.restapi.MergeConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
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.Set;
import java.util.UUID;
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,
ChangeMergedListener,
ChangeRestoredListener,
CommentAddedListener,
DraftPublishedListener,
RevisionCreatedListener,
TopicEditedListener {
private static final Logger log = LoggerFactory.getLogger(DownstreamCreator.class);
protected GerritApi gApi;
protected ConfigLoader config;
@Inject
public DownstreamCreator(GerritApi gApi, ConfigLoader config) {
this.gApi = gApi;
this.config = config;
}
/**
* Updates the config in memory if the config project is updated.
*
* @param event Event we are listening to.
*/
@Override
public void onChangeMerged(ChangeMergedListener.Event event) {
ChangeInfo change = event.getChange();
try {
if (change.project.equals(config.configProject)
&& change.branch.equals(config.configProjectBranch)) {
loadConfig();
}
} catch (RestApiException | IOException e) {
log.error("Failed to reload config at {}", change.id, e);
}
}
/**
* Abandons downstream changes if a change is abandoned.
*
* @param event Event we are listening to.
*/
@Override
public void onChangeAbandoned(ChangeAbandonedListener.Event event) {
ChangeInfo change = event.getChange();
String revision = event.getRevision().commit.commit;
log.debug("Detected revision {} abandoned on {}.", revision, change.project);
abandonDownstream(change, revision);
}
/**
* Updates downstream topics if a change has its topic modified.
*
* @param event Event we are listening to.
*/
@Override
public void onTopicEdited(TopicEditedListener.Event event) {
ChangeInfo change = event.getChange();
String oldTopic = event.getOldTopic();
String revision = change.currentRevision;
Set<String> downstreamBranches;
try {
downstreamBranches = config.getDownstreamBranches(change.branch, change.project);
} catch (RestApiException | IOException 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;
}
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 e) {
log.error("RestApiException when editing downstream topics of {}", change.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) {
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 = change.currentRevision;
Set<String> downstreamBranches;
try {
downstreamBranches = config.getDownstreamBranches(change.branch, change.project);
} catch (RestApiException | IOException e) {
log.error("Failed to update downstream votes of {}", change.id, e);
return;
}
if (downstreamBranches.isEmpty()) {
log.debug("Downstream branches of {} on {} are empty", change.branch, change.project);
return;
}
Map<String, ApprovalInfo> approvals = event.getApprovals();
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, ApprovalInfo> label : approvals.entrySet()) {
updateVote(downstreamChange, label.getKey(), label.getValue().value.shortValue());
}
}
} catch (RestApiException e) {
log.error("RestApiException when updating downstream votes of {}", change.id, e);
}
}
}
/**
* Automerges changes downstream if a change is restored.
*
* @param event Event we are listening to.
*/
@Override
public void onChangeRestored(ChangeRestoredListener.Event event) {
ChangeInfo change = event.getChange();
try {
automergeChanges(change, event.getRevision());
} catch (RestApiException | IOException e) {
log.error("Failed to edit downstream topics of {}", change.id, e);
}
}
/**
* Automerges changes downstream if a draft is published.
*
* @param event Event we are listening to.
*/
@Override
public void onDraftPublished(DraftPublishedListener.Event event) {
ChangeInfo change = event.getChange();
try {
automergeChanges(change, event.getRevision());
} catch (RestApiException | IOException e) {
log.error("Failed to edit downstream topics of {}", change.id, e);
}
}
/**
* Automerges changes downstream if a revision is created.
*
* @param event Event we are listening to.
*/
@Override
public void onRevisionCreated(RevisionCreatedListener.Event event) {
ChangeInfo change = event.getChange();
try {
automergeChanges(change, event.getRevision());
} catch (RestApiException | IOException e) {
log.error("Failed to edit downstream topics of {}", change.id, e);
}
}
/**
* Creates merges downstream, and votes -1 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.
*/
public void createMergesAndHandleConflicts(MultipleDownstreamMergeInput mdsMergeInput)
throws RestApiException {
ReviewInput reviewInput = new ReviewInput();
Map<String, Short> labels = new HashMap<String, Short>();
short vote = 0;
try {
createDownstreamMerges(mdsMergeInput);
reviewInput.message =
"Automerging to "
+ Joiner.on(", ").join(mdsMergeInput.dsBranchMap.keySet())
+ " succeeded!";
reviewInput.notify = NotifyHandling.NONE;
} catch (FailedMergeException e) {
reviewInput.message = e.displayConflicts();
reviewInput.notify = NotifyHandling.ALL;
vote = -1;
}
// Zero out automerge label if success, -1 vote if fail.
labels.put(config.getAutomergeLabel(), vote);
reviewInput.labels = labels;
gApi.changes()
.id(mdsMergeInput.sourceId)
.revision(mdsMergeInput.currentRevision)
.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.
*/
public void createDownstreamMerges(MultipleDownstreamMergeInput mdsMergeInput)
throws RestApiException, FailedMergeException {
Map<String, String> failedMerges = new HashMap<String, String>();
List<Integer> existingDownstream;
for (String downstreamBranch : mdsMergeInput.dsBranchMap.keySet()) {
// If there are existing downstream merges, update them
// Otherwise, create them.
try {
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) {
updateDownstreamMerge(
mdsMergeInput.currentRevision,
mdsMergeInput.subject,
dsChangeNumber,
mdsMergeInput.dsBranchMap.get(downstreamBranch));
createDownstreams = false;
}
}
}
if (createDownstreams) {
log.debug(
"Attempting to create downstream merge of {} on branch {}",
mdsMergeInput.currentRevision,
downstreamBranch);
SingleDownstreamMergeInput sdsMergeInput = new SingleDownstreamMergeInput();
sdsMergeInput.currentRevision = mdsMergeInput.currentRevision;
sdsMergeInput.sourceId = mdsMergeInput.sourceId;
sdsMergeInput.project = mdsMergeInput.project;
sdsMergeInput.topic = mdsMergeInput.topic;
sdsMergeInput.subject = mdsMergeInput.subject;
sdsMergeInput.downstreamBranch = downstreamBranch;
sdsMergeInput.doMerge = mdsMergeInput.dsBranchMap.get(downstreamBranch);
createSingleDownstreamMerge(sdsMergeInput);
}
} catch (MergeConflictException e) {
log.debug("Merge conflict from {} to {}", mdsMergeInput.currentRevision, downstreamBranch);
failedMerges.put(downstreamBranch, e.getMessage());
log.debug("Abandoning downstream of {}", mdsMergeInput.sourceId);
abandonDownstream(
gApi.changes().id(mdsMergeInput.sourceId).info(), mdsMergeInput.currentRevision);
}
}
if (!failedMerges.keySet().isEmpty()) {
throw new FailedMergeException(failedMerges);
}
}
/**
* 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.
*/
public List<Integer> getExistingMergesOnBranch(
String upstreamRevision, String topic, String downstreamBranch) throws RestApiException {
List<Integer> downstreamChangeNumbers = new ArrayList<Integer>();
// get changes in same topic and check if their parent is upstreamRevision
String query = "topic:" + topic + " status:open branch:" + downstreamBranch;
List<ChangeInfo> changes =
gApi.changes()
.query(query)
.withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
.get();
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
*/
public void createSingleDownstreamMerge(SingleDownstreamMergeInput sdsMergeInput)
throws RestApiException {
String currentTopic = setTopic(sdsMergeInput.sourceId, sdsMergeInput.topic);
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 =
sdsMergeInput.subject + " am: " + sdsMergeInput.currentRevision.substring(0, 10);
downstreamChangeInput.topic = currentTopic;
downstreamChangeInput.merge = mergeInput;
if (!sdsMergeInput.doMerge) {
mergeInput.strategy = "ours";
downstreamChangeInput.subject =
sdsMergeInput.subject + " skipped: " + sdsMergeInput.currentRevision.substring(0, 10);
log.debug(
"Skipping merge for {} to {}",
sdsMergeInput.currentRevision,
sdsMergeInput.downstreamBranch);
}
gApi.changes().create(downstreamChangeInput);
}
private void loadConfig() throws IOException, RestApiException {
try {
config.loadConfig();
} catch (IOException | RestApiException e) {
log.error("Config failed to sync!", e);
throw e;
}
}
private void automergeChanges(ChangeInfo change, RevisionInfo revisionInfo)
throws RestApiException, IOException {
if (revisionInfo.draft != null && revisionInfo.draft) {
log.debug("Patchset {} is draft change, ignoring.", revisionInfo.commit.commit);
return;
}
String currentRevision = revisionInfo.commit.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<String, Boolean>();
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.sourceId = change.id;
mdsMergeInput.project = change.project;
mdsMergeInput.topic = change.topic;
mdsMergeInput.subject = change.subject;
mdsMergeInput.obsoleteRevision = previousRevision;
mdsMergeInput.currentRevision = currentRevision;
createMergesAndHandleConflicts(mdsMergeInput);
}
private void abandonDownstream(ChangeInfo change, String revision) {
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 e) {
log.error("Failed to abandon downstreams of {}", change.id, e);
}
}
private void updateVote(ChangeInfo change, String label, short vote) throws RestApiException {
if (label.equals(config.getAutomergeLabel())) {
log.debug("Not updating automerge label, as it blocks when there is a merge conflict.");
return;
}
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<String, Short>();
labels.put(label, vote);
reviewInput.labels = labels;
gApi.changes().id(change.id).revision(change.currentRevision).review(reviewInput);
}
private void updateDownstreamMerge(
String newParentRevision, String upstreamSubject, Integer sourceNum, boolean doMerge)
throws RestApiException {
MergeInput mergeInput = new MergeInput();
mergeInput.source = newParentRevision;
MergePatchSetInput mergePatchSetInput = new MergePatchSetInput();
mergePatchSetInput.subject = upstreamSubject + " am: " + newParentRevision.substring(0, 10);
if (!doMerge) {
mergeInput.strategy = "ours";
mergePatchSetInput.subject =
upstreamSubject + " skipped: " + newParentRevision.substring(0, 10);
log.debug("Skipping merge for {} on {}", newParentRevision, sourceNum);
}
mergePatchSetInput.merge = mergeInput;
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 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 setTopic(String sourceId, String topic) throws RestApiException {
if (topic == null || topic.isEmpty()) {
topic = "am-" + UUID.randomUUID().toString();
log.debug("Setting original change {} topic to {}", sourceId, topic);
gApi.changes().id(sourceId).topic(topic);
}
return topic;
}
}