Refactored out commit validation code from ReceiveCommits.
Refactored out commit validation logic from ReceiveCommits to a new CommitValidators class. Other parts of Gerrit that allow the user to create new Changes (CherryPick, Revert, Rebase) should be able to reuse selected parts of this validation code. Each separate entity of validation logic will now reside its own class, implementing the CommitValidationListener interface. While this approach will generate some more code, the maintainability should be far more sustainable, especially when we start to reuse the code in other places in Gerrit. In the future, some generalization of the error messages will probably be needed so they fit a broader scope and not only when the user is pushing the change using Git. Change-Id: I99c807742ae06571c023249f7d965b633359e7ac
This commit is contained in:
parent
986f2cbe96
commit
936470ed2e
@ -33,6 +33,7 @@ import com.google.gerrit.server.git.CreateCodeReviewNotes;
|
||||
import com.google.gerrit.server.git.MergeOp;
|
||||
import com.google.gerrit.server.git.MetaDataUpdate;
|
||||
import com.google.gerrit.server.git.SubmoduleOp;
|
||||
import com.google.gerrit.server.git.validators.CommitValidators;
|
||||
import com.google.gerrit.server.mail.AddReviewerSender;
|
||||
import com.google.gerrit.server.mail.CommitMessageEditedSender;
|
||||
import com.google.gerrit.server.mail.CreateChangeSender;
|
||||
@ -91,5 +92,6 @@ public class GerritRequestModule extends FactoryModule {
|
||||
factory(CreateProject.Factory.class);
|
||||
factory(SuggestParentCandidates.Factory.class);
|
||||
factory(BanCommit.Factory.class);
|
||||
factory(CommitValidators.Factory.class);
|
||||
}
|
||||
}
|
||||
|
@ -39,10 +39,8 @@ import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.ListeningExecutorService;
|
||||
import com.google.gerrit.common.ChangeHookRunner.HookResult;
|
||||
import com.google.gerrit.common.ChangeHooks;
|
||||
import com.google.gerrit.common.PageLinks;
|
||||
import com.google.gerrit.common.data.Capable;
|
||||
import com.google.gerrit.common.data.PermissionRule;
|
||||
import com.google.gerrit.extensions.registration.DynamicSet;
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.reviewdb.client.Branch;
|
||||
import com.google.gerrit.reviewdb.client.Change;
|
||||
@ -66,8 +64,8 @@ import com.google.gerrit.server.events.CommitReceivedEvent;
|
||||
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
|
||||
import com.google.gerrit.server.git.MultiProgressMonitor.Task;
|
||||
import com.google.gerrit.server.git.validators.CommitValidationException;
|
||||
import com.google.gerrit.server.git.validators.CommitValidationListener;
|
||||
import com.google.gerrit.server.git.validators.CommitValidationMessage;
|
||||
import com.google.gerrit.server.git.validators.CommitValidators;
|
||||
import com.google.gerrit.server.mail.CreateChangeSender;
|
||||
import com.google.gerrit.server.mail.MailUtil.MailRecipients;
|
||||
import com.google.gerrit.server.mail.MergedSender;
|
||||
@ -88,8 +86,6 @@ import com.google.gwtorm.server.SchemaFactory;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.assistedinject.Assisted;
|
||||
|
||||
import com.jcraft.jsch.HostKey;
|
||||
|
||||
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
|
||||
import org.eclipse.jgit.errors.MissingObjectException;
|
||||
import org.eclipse.jgit.lib.AbbreviatedObjectId;
|
||||
@ -115,13 +111,10 @@ import org.eclipse.jgit.transport.ReceiveCommand;
|
||||
import org.eclipse.jgit.transport.ReceiveCommand.Result;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.UploadPack;
|
||||
import org.eclipse.jgit.util.SystemReader;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@ -251,7 +244,7 @@ public class ReceiveCommits {
|
||||
private final GitRepositoryManager repoManager;
|
||||
private final ProjectCache projectCache;
|
||||
private final String canonicalWebUrl;
|
||||
private final PersonIdent gerritIdent;
|
||||
private final CommitValidators.Factory commitValidatorsFactory;
|
||||
private final TrackingFooters trackingFooters;
|
||||
private final TagCache tagCache;
|
||||
private final WorkQueue workQueue;
|
||||
@ -290,7 +283,6 @@ public class ReceiveCommits {
|
||||
private Task commandProgress;
|
||||
private MessageSender messageSender;
|
||||
private BatchRefUpdate batch;
|
||||
private final DynamicSet<CommitValidationListener> commitValidators;
|
||||
|
||||
@Inject
|
||||
ReceiveCommits(final ReviewDb db,
|
||||
@ -307,6 +299,7 @@ public class ReceiveCommits {
|
||||
final GitRepositoryManager repoManager,
|
||||
final TagCache tagCache,
|
||||
final ChangeCache changeCache,
|
||||
final CommitValidators.Factory commitValidatorsFactory,
|
||||
@CanonicalWebUrl @Nullable final String canonicalWebUrl,
|
||||
@GerritPersonIdent final PersonIdent gerritIdent,
|
||||
final TrackingFooters trackingFooters,
|
||||
@ -314,7 +307,6 @@ public class ReceiveCommits {
|
||||
@ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
|
||||
final RequestScopePropagator requestScopePropagator,
|
||||
final SshInfo sshInfo,
|
||||
final DynamicSet<CommitValidationListener> commitValidationListeners,
|
||||
final AllProjectsName allProjectsName,
|
||||
@Assisted final ProjectControl projectControl,
|
||||
@Assisted final Repository repo,
|
||||
@ -333,9 +325,9 @@ public class ReceiveCommits {
|
||||
this.projectCache = projectCache;
|
||||
this.repoManager = repoManager;
|
||||
this.canonicalWebUrl = canonicalWebUrl;
|
||||
this.gerritIdent = gerritIdent;
|
||||
this.trackingFooters = trackingFooters;
|
||||
this.tagCache = tagCache;
|
||||
this.commitValidatorsFactory = commitValidatorsFactory;
|
||||
this.workQueue = workQueue;
|
||||
this.changeUpdateExector = changeUpdateExector;
|
||||
this.requestScopePropagator = requestScopePropagator;
|
||||
@ -349,7 +341,6 @@ public class ReceiveCommits {
|
||||
this.rejectCommits = loadRejectCommitsMap();
|
||||
|
||||
this.subOpFactory = subOpFactory;
|
||||
this.commitValidators = commitValidationListeners;
|
||||
|
||||
this.messageSender = new ReceivePackMessageSender();
|
||||
|
||||
@ -1193,6 +1184,7 @@ public class ReceiveCommits {
|
||||
//
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validCommit(destBranchCtl, newChange, c)) {
|
||||
// Not a change the user can propose? Abort as early as possible.
|
||||
//
|
||||
@ -1535,6 +1527,7 @@ public class ReceiveCommits {
|
||||
}
|
||||
|
||||
rp.getRevWalk().parseBody(newCommit);
|
||||
|
||||
if (!validCommit(changeCtl.getRefControl(), inputCommand, newCommit)) {
|
||||
return false;
|
||||
}
|
||||
@ -1888,279 +1881,22 @@ public class ReceiveCommits {
|
||||
|
||||
private boolean validCommit(final RefControl ctl, final ReceiveCommand cmd,
|
||||
final RevCommit c) throws MissingObjectException, IOException {
|
||||
rp.getRevWalk().parseBody(c);
|
||||
final PersonIdent committer = c.getCommitterIdent();
|
||||
final PersonIdent author = c.getAuthorIdent();
|
||||
|
||||
// Require permission to upload merges.
|
||||
if (c.getParentCount() > 1 && !ctl.canUploadMerges()) {
|
||||
reject(cmd, "you are not allowed to upload merges");
|
||||
CommitReceivedEvent receiveEvent =
|
||||
new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, currentUser);
|
||||
CommitValidators commitValidators =
|
||||
commitValidatorsFactory.create(ctl, sshInfo, repo);
|
||||
|
||||
try {
|
||||
messages.addAll(commitValidators.validateForReceiveCommits(receiveEvent));
|
||||
} catch (CommitValidationException e) {
|
||||
messages.addAll(e.getMessages());
|
||||
reject(cmd, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't allow the user to amend a merge created by Gerrit Code Review.
|
||||
// This seems to happen all too often, due to users not paying any
|
||||
// attention to what they are doing.
|
||||
//
|
||||
if (c.getParentCount() > 1
|
||||
&& author.getName().equals(gerritIdent.getName())
|
||||
&& author.getEmailAddress().equals(gerritIdent.getEmailAddress())
|
||||
&& !ctl.canForgeGerritServerIdentity()) {
|
||||
reject(cmd, "do not amend merges not made by you");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Require that author matches the uploader.
|
||||
//
|
||||
if (!currentUser.getEmailAddresses().contains(author.getEmailAddress())
|
||||
&& !ctl.canForgeAuthor()) {
|
||||
sendInvalidEmailError(c, "author", author);
|
||||
reject(cmd, "invalid author");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Require that committer matches the uploader.
|
||||
//
|
||||
if (!currentUser.getEmailAddresses().contains(committer.getEmailAddress())
|
||||
&& !ctl.canForgeCommitter()) {
|
||||
sendInvalidEmailError(c, "committer", committer);
|
||||
reject(cmd, "invalid committer");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projectControl.getProjectState().isUseSignedOffBy()) {
|
||||
// If the project wants Signed-off-by / Acked-by lines, verify we
|
||||
// have them for the blamable parties involved on this change.
|
||||
//
|
||||
boolean sboAuthor = false, sboCommitter = false, sboMe = false;
|
||||
for (final FooterLine footer : c.getFooterLines()) {
|
||||
if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
|
||||
final String e = footer.getEmailAddress();
|
||||
if (e != null) {
|
||||
sboAuthor |= author.getEmailAddress().equals(e);
|
||||
sboCommitter |= committer.getEmailAddress().equals(e);
|
||||
sboMe |= currentUser.getEmailAddresses().contains(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sboAuthor && !sboCommitter && !sboMe && !ctl.canForgeCommitter()) {
|
||||
reject(cmd, "not Signed-off-by author/committer/uploader in commit message footer");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final List<String> idList = c.getFooterLines(CHANGE_ID);
|
||||
if (MagicBranch.isMagicBranch(cmd.getRefName()) || NEW_PATCHSET.matcher(cmd.getRefName()).matches()) {
|
||||
if (idList.isEmpty()) {
|
||||
if (projectControl.getProjectState().isRequireChangeID()) {
|
||||
String errMsg = "missing Change-Id in commit message footer";
|
||||
reject(cmd, errMsg);
|
||||
addMessage(getFixedCommitMsgWithChangeId(errMsg, c));
|
||||
return false;
|
||||
}
|
||||
} else if (idList.size() > 1) {
|
||||
reject(cmd, "multiple Change-Id lines in commit message footer");
|
||||
return false;
|
||||
} else {
|
||||
final String v = idList.get(idList.size() - 1).trim();
|
||||
if (!v.matches("^I[0-9a-f]{8,}.*$")) {
|
||||
final String errMsg =
|
||||
"missing or invalid Change-Id line format in commit message footer";
|
||||
reject(cmd, errMsg);
|
||||
addMessage(getFixedCommitMsgWithChangeId(errMsg, c));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for banned commits to prevent them from entering the tree again.
|
||||
if (rejectCommits.contains(c)) {
|
||||
reject(cmd, "contains banned commit " + c.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this is the special project configuration branch, validate the config.
|
||||
if (GitRepositoryManager.REF_CONFIG.equals(ctl.getRefName())) {
|
||||
try {
|
||||
ProjectConfig cfg = new ProjectConfig(project.getNameKey());
|
||||
cfg.load(repo, cmd.getNewId());
|
||||
if (!cfg.getValidationErrors().isEmpty()) {
|
||||
addError("Invalid project configuration:");
|
||||
for (ValidationError err : cfg.getValidationErrors()) {
|
||||
addError(" " + err.getMessage());
|
||||
}
|
||||
reject(cmd, "invalid project configuration");
|
||||
log.error("User " + currentUser.getUserName()
|
||||
+ " tried to push invalid project configuration "
|
||||
+ cmd.getNewId().name() + " for " + project.getName());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
reject(cmd, "invalid project configuration");
|
||||
log.error("User " + currentUser.getUserName()
|
||||
+ " tried to push invalid project configuration "
|
||||
+ cmd.getNewId().name() + " for " + project.getName(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute commit validation plugins
|
||||
for (CommitValidationListener validator : commitValidators) {
|
||||
try {
|
||||
messages.addAll(validator.onCommitReceived(new CommitReceivedEvent(
|
||||
cmd, project, ctl.getRefName(), c, currentUser)));
|
||||
} catch (CommitValidationException error) {
|
||||
messages.addAll(error.getMessages());
|
||||
reject(cmd, error.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Gerrit hostname.
|
||||
* @return the hostname from the canonical URL if it is configured,
|
||||
* otherwise whatever the OS says the hostname is.
|
||||
*/
|
||||
private String getGerritHost() {
|
||||
String host;
|
||||
if (canonicalWebUrl != null) {
|
||||
try {
|
||||
host = new URL(canonicalWebUrl).getHost();
|
||||
} catch (MalformedURLException e) {
|
||||
host = SystemReader.getInstance().getHostname();
|
||||
}
|
||||
} else {
|
||||
host = SystemReader.getInstance().getHostname();
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Gerrit URL.
|
||||
* @return the canonical URL (with any trailing slash removed) if it is
|
||||
* configured, otherwise fall back to "http://hostname" where hostname is
|
||||
* the value returned by {@link #getGerritHost()}.
|
||||
*/
|
||||
private String getGerritUrl() {
|
||||
if (canonicalWebUrl != null) {
|
||||
if (canonicalWebUrl.endsWith("/")) {
|
||||
return canonicalWebUrl.substring(0, canonicalWebUrl.lastIndexOf("/"));
|
||||
}
|
||||
return canonicalWebUrl;
|
||||
} else {
|
||||
return "http://" + getGerritHost();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text with instructions for installing the commit-msg hook, specific
|
||||
* to the server hostname and transport protocol.
|
||||
* @return commit-msg hook installation instructions as a String.
|
||||
*/
|
||||
private String getCommitMessageHookInstallationHint() {
|
||||
final List<HostKey> hostKeys = sshInfo.getHostKeys();
|
||||
|
||||
// If there are no SSH keys, the commit-msg hook must be installed via HTTP(S)
|
||||
if (hostKeys.isEmpty()) {
|
||||
String p = ".git/hooks/commit-msg";
|
||||
return String.format(
|
||||
" curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s",
|
||||
p, getGerritUrl(), p);
|
||||
}
|
||||
|
||||
// SSH keys exist, so the hook can be installed with scp.
|
||||
String sshHost;
|
||||
int sshPort;
|
||||
String host = hostKeys.get(0).getHost();
|
||||
int c = host.lastIndexOf(':');
|
||||
if (0 <= c) {
|
||||
if (host.startsWith("*:")) {
|
||||
sshHost = getGerritHost();
|
||||
} else {
|
||||
sshHost = host.substring(0, c);
|
||||
}
|
||||
sshPort = Integer.parseInt(host.substring(c+1));
|
||||
} else {
|
||||
sshHost = host;
|
||||
sshPort = 22;
|
||||
}
|
||||
|
||||
return String.format(
|
||||
" scp -p -P %d %s@%s:hooks/commit-msg .git/hooks/",
|
||||
sshPort, currentUser.getUserName(), sshHost);
|
||||
}
|
||||
|
||||
private String getFixedCommitMsgWithChangeId(String errMsg, RevCommit c) {
|
||||
// We handle 3 cases:
|
||||
// 1. No change id in the commit message at all.
|
||||
// 2. change id last in the commit message but missing empty line to create the footer.
|
||||
// 3. there is a change-id somewhere in the commit message, but we ignore it.
|
||||
final String changeId = "Change-Id:";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ERROR: ").append(errMsg);
|
||||
sb.append('\n');
|
||||
sb.append("Suggestion for commit message:\n");
|
||||
|
||||
if (c.getFullMessage().indexOf(changeId)==-1) {
|
||||
sb.append(c.getFullMessage());
|
||||
sb.append('\n');
|
||||
sb.append(changeId).append(" I").append(c.name());
|
||||
} else {
|
||||
String lines[] = c.getFullMessage().trim().split("\n");
|
||||
String lastLine = lines.length > 0 ? lines[lines.length - 1] : "";
|
||||
|
||||
if (lastLine.indexOf(changeId)==0) {
|
||||
for (int i = 0; i < lines.length - 1; i++) {
|
||||
sb.append(lines[i]);
|
||||
sb.append('\n');
|
||||
}
|
||||
|
||||
sb.append('\n');
|
||||
sb.append(lastLine);
|
||||
} else {
|
||||
sb.append(c.getFullMessage());
|
||||
sb.append('\n');
|
||||
sb.append(changeId).append(" I").append(c.name());
|
||||
sb.append('\n');
|
||||
sb.append("Hint: A potential Change-Id was found, but it was not in the footer of the commit message.");
|
||||
}
|
||||
}
|
||||
sb.append('\n');
|
||||
sb.append('\n');
|
||||
sb.append("Hint: To automatically insert Change-Id, install the hook:\n");
|
||||
sb.append(getCommitMessageHookInstallationHint()).append('\n');
|
||||
sb.append('\n');
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void sendInvalidEmailError(RevCommit c, String type, PersonIdent who) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append("ERROR: In commit " + c.name() + "\n");
|
||||
sb.append("ERROR: " + type + " email address " + who.getEmailAddress() + "\n");
|
||||
sb.append("ERROR: does not match your user account.\n");
|
||||
sb.append("ERROR:\n");
|
||||
if (currentUser.getEmailAddresses().isEmpty()) {
|
||||
sb.append("ERROR: You have not registered any email addresses.\n");
|
||||
} else {
|
||||
sb.append("ERROR: The following addresses are currently registered:\n");
|
||||
for (String address : currentUser.getEmailAddresses()) {
|
||||
sb.append("ERROR: " + address + "\n");
|
||||
}
|
||||
}
|
||||
sb.append("ERROR:\n");
|
||||
if (canonicalWebUrl != null) {
|
||||
sb.append("ERROR: To register an email address, please visit:\n");
|
||||
sb.append("ERROR: " + canonicalWebUrl + "#" + PageLinks.SETTINGS_CONTACT + "\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
addMessage(sb.toString());
|
||||
}
|
||||
|
||||
private void warnMalformedMessage(RevCommit c) {
|
||||
ObjectReader reader = rp.getRevWalk().getObjectReader();
|
||||
if (65 < c.getShortMessage().length()) {
|
||||
|
@ -0,0 +1,546 @@
|
||||
// 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.git.validators;
|
||||
|
||||
import com.google.gerrit.common.PageLinks;
|
||||
import com.google.gerrit.extensions.registration.DynamicSet;
|
||||
import com.google.gerrit.server.GerritPersonIdent;
|
||||
import com.google.gerrit.server.IdentifiedUser;
|
||||
import com.google.gerrit.server.config.CanonicalWebUrl;
|
||||
import com.google.gerrit.server.events.CommitReceivedEvent;
|
||||
import com.google.gerrit.server.git.GitRepositoryManager;
|
||||
import com.google.gerrit.server.git.ProjectConfig;
|
||||
import com.google.gerrit.server.git.ValidationError;
|
||||
import com.google.gerrit.server.project.ProjectControl;
|
||||
import com.google.gerrit.server.project.RefControl;
|
||||
import com.google.gerrit.server.ssh.SshInfo;
|
||||
import com.google.gerrit.server.util.MagicBranch;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.assistedinject.Assisted;
|
||||
|
||||
import com.jcraft.jsch.HostKey;
|
||||
|
||||
import org.eclipse.jgit.errors.ConfigInvalidException;
|
||||
import org.eclipse.jgit.lib.PersonIdent;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.revwalk.FooterKey;
|
||||
import org.eclipse.jgit.revwalk.FooterLine;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.util.SystemReader;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class CommitValidators {
|
||||
private static final Logger log = LoggerFactory
|
||||
.getLogger(CommitValidators.class);
|
||||
|
||||
private static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
|
||||
|
||||
private static final Pattern NEW_PATCHSET = Pattern
|
||||
.compile("^refs/changes/(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/new)?$");
|
||||
|
||||
public interface Factory {
|
||||
CommitValidators create(RefControl refControl, SshInfo sshInfo,
|
||||
Repository repo);
|
||||
}
|
||||
|
||||
private final PersonIdent gerritIdent;
|
||||
private final RefControl refControl;
|
||||
private final String canonicalWebUrl;
|
||||
private final SshInfo sshInfo;
|
||||
private final Repository repo;
|
||||
private final DynamicSet<CommitValidationListener> commitValidationListeners;
|
||||
|
||||
@Inject
|
||||
CommitValidators(@GerritPersonIdent final PersonIdent gerritIdent,
|
||||
final SshInfo sshInfo,
|
||||
@CanonicalWebUrl @Nullable final String canonicalWebUrl,
|
||||
final DynamicSet<CommitValidationListener> commitValidationListeners,
|
||||
@Assisted final Repository repo, @Assisted final RefControl refControl) {
|
||||
this.gerritIdent = gerritIdent;
|
||||
this.refControl = refControl;
|
||||
this.canonicalWebUrl = canonicalWebUrl;
|
||||
this.sshInfo = sshInfo;
|
||||
this.repo = repo;
|
||||
this.commitValidationListeners = commitValidationListeners;
|
||||
}
|
||||
|
||||
public List<CommitValidationMessage> validateForReceiveCommits(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
|
||||
List<CommitValidationListener> validators =
|
||||
new LinkedList<CommitValidationListener>();
|
||||
|
||||
validators.add(new UploadMergesPermissionValidator(refControl));
|
||||
validators.add(new AmendedGerritMergeCommitValidationListener(
|
||||
refControl, gerritIdent));
|
||||
validators.add(new AuthorUploaderValidator(refControl, canonicalWebUrl));
|
||||
validators.add(new CommitterUploaderValidator(refControl, canonicalWebUrl));
|
||||
validators.add(new SignedOffByValidator(refControl, canonicalWebUrl));
|
||||
validators.add(new ChangeIdValidator(refControl, canonicalWebUrl, sshInfo));
|
||||
validators.add(new ConfigValidator(refControl, repo));
|
||||
validators.add(new PluginCommitValidationListener(commitValidationListeners));
|
||||
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
|
||||
try {
|
||||
for (CommitValidationListener commitValidator : validators) {
|
||||
messages.addAll(commitValidator.onCommitReceived(receiveEvent));
|
||||
}
|
||||
} catch (CommitValidationException e) {
|
||||
// Keep the old messages (and their order) in case of an exception
|
||||
messages.addAll(e.getMessages());
|
||||
throw new CommitValidationException(e.getMessage(), messages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
public static class ChangeIdValidator implements CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
private final String canonicalWebUrl;
|
||||
private final SshInfo sshInfo;
|
||||
|
||||
public ChangeIdValidator(RefControl refControl, String canonicalWebUrl,
|
||||
SshInfo sshInfo) {
|
||||
this.refControl = refControl;
|
||||
this.canonicalWebUrl = canonicalWebUrl;
|
||||
this.sshInfo = sshInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
|
||||
final ProjectControl projectControl = refControl.getProjectControl();
|
||||
IdentifiedUser currentUser = (IdentifiedUser) refControl.getCurrentUser();
|
||||
final List<String> idList = receiveEvent.commit.getFooterLines(CHANGE_ID);
|
||||
|
||||
if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
|
||||
|| NEW_PATCHSET.matcher(receiveEvent.command.getRefName()).matches()) {
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
|
||||
if (idList.isEmpty()) {
|
||||
if (projectControl.getProjectState().isRequireChangeID()) {
|
||||
String errMsg = "missing Change-Id in commit message footer";
|
||||
messages.add(getFixedCommitMsgWithChangeId(errMsg, receiveEvent.commit,
|
||||
currentUser, canonicalWebUrl, sshInfo));
|
||||
throw new CommitValidationException(errMsg, messages);
|
||||
}
|
||||
} else if (idList.size() > 1) {
|
||||
throw new CommitValidationException(
|
||||
"multiple Change-Id lines in commit message footer", messages);
|
||||
} else {
|
||||
final String v = idList.get(idList.size() - 1).trim();
|
||||
if (!v.matches("^I[0-9a-f]{8,}.*$")) {
|
||||
final String errMsg =
|
||||
"missing or invalid Change-Id line format in commit message footer";
|
||||
messages.add(getFixedCommitMsgWithChangeId(errMsg, receiveEvent.commit,
|
||||
currentUser, canonicalWebUrl, sshInfo));
|
||||
throw new CommitValidationException(errMsg, messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If this is the special project configuration branch, validate the config.
|
||||
*/
|
||||
public static class ConfigValidator implements CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
private final Repository repo;
|
||||
|
||||
public ConfigValidator(RefControl refControl, Repository repo) {
|
||||
this.refControl = refControl;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
IdentifiedUser currentUser = (IdentifiedUser) refControl.getCurrentUser();
|
||||
|
||||
if (GitRepositoryManager.REF_CONFIG.equals(refControl.getRefName())) {
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
|
||||
try {
|
||||
ProjectConfig cfg =
|
||||
new ProjectConfig(receiveEvent.project.getNameKey());
|
||||
cfg.load(repo, receiveEvent.command.getNewId());
|
||||
if (!cfg.getValidationErrors().isEmpty()) {
|
||||
addError("Invalid project configuration:", messages);
|
||||
for (ValidationError err : cfg.getValidationErrors()) {
|
||||
addError(" " + err.getMessage(), messages);
|
||||
}
|
||||
throw new ConfigInvalidException("invalid project configuration");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("User " + currentUser.getUserName()
|
||||
+ " tried to push invalid project configuration "
|
||||
+ receiveEvent.command.getNewId().name() + " for "
|
||||
+ receiveEvent.project.getName(), e);
|
||||
throw new CommitValidationException("invalid project configuration",
|
||||
messages);
|
||||
}
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/** Require permission to upload merges. */
|
||||
public static class UploadMergesPermissionValidator implements
|
||||
CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
|
||||
public UploadMergesPermissionValidator(RefControl refControl) {
|
||||
this.refControl = refControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
if (receiveEvent.commit.getParentCount() > 1
|
||||
&& !refControl.canUploadMerges()) {
|
||||
throw new CommitValidationException("you are not allowed to upload merges");
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute commit validation plug-ins */
|
||||
public static class PluginCommitValidationListener implements
|
||||
CommitValidationListener {
|
||||
private final DynamicSet<CommitValidationListener> commitValidationListeners;
|
||||
|
||||
public PluginCommitValidationListener(
|
||||
final DynamicSet<CommitValidationListener> commitValidationListeners) {
|
||||
this.commitValidationListeners = commitValidationListeners;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
|
||||
for (CommitValidationListener validator : commitValidationListeners) {
|
||||
try {
|
||||
messages.addAll(validator.onCommitReceived(receiveEvent));
|
||||
} catch (CommitValidationException e) {
|
||||
messages.addAll(e.getMessages());
|
||||
throw new CommitValidationException(e.getMessage(), messages);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
public static class SignedOffByValidator implements CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
|
||||
public SignedOffByValidator(RefControl refControl, String canonicalWebUrl) {
|
||||
this.refControl = refControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
IdentifiedUser currentUser = (IdentifiedUser) refControl.getCurrentUser();
|
||||
final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
|
||||
final PersonIdent author = receiveEvent.commit.getAuthorIdent();
|
||||
final ProjectControl projectControl = refControl.getProjectControl();
|
||||
|
||||
if (projectControl.getProjectState().isUseSignedOffBy()) {
|
||||
boolean sboAuthor = false, sboCommitter = false, sboMe = false;
|
||||
for (final FooterLine footer : receiveEvent.commit.getFooterLines()) {
|
||||
if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
|
||||
final String e = footer.getEmailAddress();
|
||||
if (e != null) {
|
||||
sboAuthor |= author.getEmailAddress().equals(e);
|
||||
sboCommitter |= committer.getEmailAddress().equals(e);
|
||||
sboMe |= currentUser.getEmailAddresses().contains(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sboAuthor && !sboCommitter && !sboMe
|
||||
&& !refControl.canForgeCommitter()) {
|
||||
throw new CommitValidationException(
|
||||
"not Signed-off-by author/committer/uploader in commit message footer");
|
||||
}
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/** Require that author matches the uploader. */
|
||||
public static class AuthorUploaderValidator implements
|
||||
CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
private final String canonicalWebUrl;
|
||||
|
||||
public AuthorUploaderValidator(RefControl refControl, String canonicalWebUrl) {
|
||||
this.refControl = refControl;
|
||||
this.canonicalWebUrl = canonicalWebUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
IdentifiedUser currentUser = (IdentifiedUser) refControl.getCurrentUser();
|
||||
final PersonIdent author = receiveEvent.commit.getAuthorIdent();
|
||||
|
||||
if (!currentUser.getEmailAddresses().contains(author.getEmailAddress())
|
||||
&& !refControl.canForgeAuthor()) {
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
|
||||
messages.add(getInvalidEmailError(receiveEvent.commit, "author", author,
|
||||
currentUser, canonicalWebUrl));
|
||||
throw new CommitValidationException("invalid author", messages);
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/** Require that committer matches the uploader. */
|
||||
public static class CommitterUploaderValidator implements
|
||||
CommitValidationListener {
|
||||
private final RefControl refControl;
|
||||
private final String canonicalWebUrl;
|
||||
|
||||
public CommitterUploaderValidator(RefControl refControl,
|
||||
String canonicalWebUrl) {
|
||||
this.refControl = refControl;
|
||||
this.canonicalWebUrl = canonicalWebUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
IdentifiedUser currentUser = (IdentifiedUser) refControl.getCurrentUser();
|
||||
final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
|
||||
if (!currentUser.getEmailAddresses()
|
||||
.contains(committer.getEmailAddress())
|
||||
&& !refControl.canForgeCommitter()) {
|
||||
List<CommitValidationMessage> messages =
|
||||
new LinkedList<CommitValidationMessage>();
|
||||
messages.add(getInvalidEmailError(receiveEvent.commit, "committer", committer,
|
||||
currentUser, canonicalWebUrl));
|
||||
throw new CommitValidationException("invalid committer", messages);
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't allow the user to amend a merge created by Gerrit Code Review. This
|
||||
* seems to happen all too often, due to users not paying any attention to
|
||||
* what they are doing.
|
||||
*/
|
||||
public static class AmendedGerritMergeCommitValidationListener implements
|
||||
CommitValidationListener {
|
||||
private final PersonIdent gerritIdent;
|
||||
private final RefControl refControl;
|
||||
|
||||
public AmendedGerritMergeCommitValidationListener(
|
||||
final RefControl refControl, final PersonIdent gerritIdent) {
|
||||
this.refControl = refControl;
|
||||
this.gerritIdent = gerritIdent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommitValidationMessage> onCommitReceived(
|
||||
CommitReceivedEvent receiveEvent) throws CommitValidationException {
|
||||
final PersonIdent author = receiveEvent.commit.getAuthorIdent();
|
||||
|
||||
if (receiveEvent.commit.getParentCount() > 1
|
||||
&& author.getName().equals(gerritIdent.getName())
|
||||
&& author.getEmailAddress().equals(gerritIdent.getEmailAddress())
|
||||
&& !refControl.canForgeGerritServerIdentity()) {
|
||||
throw new CommitValidationException("do not amend merges not made by you");
|
||||
}
|
||||
return Collections.<CommitValidationMessage>emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private static CommitValidationMessage getInvalidEmailError(RevCommit c, String type,
|
||||
PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append("ERROR: In commit " + c.name() + "\n");
|
||||
sb.append("ERROR: " + type + " email address " + who.getEmailAddress()
|
||||
+ "\n");
|
||||
sb.append("ERROR: does not match your user account.\n");
|
||||
sb.append("ERROR:\n");
|
||||
if (currentUser.getEmailAddresses().isEmpty()) {
|
||||
sb.append("ERROR: You have not registered any email addresses.\n");
|
||||
} else {
|
||||
sb.append("ERROR: The following addresses are currently registered:\n");
|
||||
for (String address : currentUser.getEmailAddresses()) {
|
||||
sb.append("ERROR: " + address + "\n");
|
||||
}
|
||||
}
|
||||
sb.append("ERROR:\n");
|
||||
if (canonicalWebUrl != null) {
|
||||
sb.append("ERROR: To register an email address, please visit:\n");
|
||||
sb.append("ERROR: " + canonicalWebUrl + "#" + PageLinks.SETTINGS_CONTACT
|
||||
+ "\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
return new CommitValidationMessage(sb.toString(), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* We handle 3 cases:
|
||||
* 1. No change id in the commit message at all.
|
||||
* 2. Change id last in the commit message but missing empty line to create the footer.
|
||||
* 3. There is a change-id somewhere in the commit message, but we ignore it.
|
||||
*
|
||||
* @return The fixed up commit message
|
||||
*/
|
||||
private static CommitValidationMessage getFixedCommitMsgWithChangeId(final String errMsg,
|
||||
final RevCommit c, final IdentifiedUser currentUser,
|
||||
String canonicalWebUrl, final SshInfo sshInfo) {
|
||||
final String changeId = "Change-Id:";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ERROR: ").append(errMsg);
|
||||
sb.append('\n');
|
||||
sb.append("Suggestion for commit message:\n");
|
||||
|
||||
if (c.getFullMessage().indexOf(changeId) == -1) {
|
||||
sb.append(c.getFullMessage());
|
||||
sb.append('\n');
|
||||
sb.append(changeId).append(" I").append(c.name());
|
||||
} else {
|
||||
String lines[] = c.getFullMessage().trim().split("\n");
|
||||
String lastLine = lines.length > 0 ? lines[lines.length - 1] : "";
|
||||
|
||||
if (lastLine.indexOf(changeId) == 0) {
|
||||
for (int i = 0; i < lines.length - 1; i++) {
|
||||
sb.append(lines[i]);
|
||||
sb.append('\n');
|
||||
}
|
||||
|
||||
sb.append('\n');
|
||||
sb.append(lastLine);
|
||||
} else {
|
||||
sb.append(c.getFullMessage());
|
||||
sb.append('\n');
|
||||
sb.append(changeId).append(" I").append(c.name());
|
||||
sb.append('\n');
|
||||
sb.append("Hint: A potential Change-Id was found, but it was not in the footer of the commit message.");
|
||||
}
|
||||
}
|
||||
sb.append('\n');
|
||||
sb.append('\n');
|
||||
sb.append("Hint: To automatically insert Change-Id, install the hook:\n");
|
||||
sb.append(getCommitMessageHookInstallationHint(currentUser,
|
||||
canonicalWebUrl, sshInfo)).append('\n');
|
||||
sb.append('\n');
|
||||
|
||||
return new CommitValidationMessage(sb.toString(), false);
|
||||
}
|
||||
|
||||
private static String getCommitMessageHookInstallationHint(
|
||||
final IdentifiedUser currentUser, String canonicalWebUrl,
|
||||
final SshInfo sshInfo) {
|
||||
final List<HostKey> hostKeys = sshInfo.getHostKeys();
|
||||
|
||||
// If there are no SSH keys, the commit-msg hook must be installed via
|
||||
// HTTP(S)
|
||||
if (hostKeys.isEmpty()) {
|
||||
String p = ".git/hooks/commit-msg";
|
||||
return String.format(
|
||||
" curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s", p,
|
||||
getGerritUrl(canonicalWebUrl), p);
|
||||
}
|
||||
|
||||
// SSH keys exist, so the hook can be installed with scp.
|
||||
String sshHost;
|
||||
int sshPort;
|
||||
String host = hostKeys.get(0).getHost();
|
||||
int c = host.lastIndexOf(':');
|
||||
if (0 <= c) {
|
||||
if (host.startsWith("*:")) {
|
||||
sshHost = getGerritHost(canonicalWebUrl);
|
||||
} else {
|
||||
sshHost = host.substring(0, c);
|
||||
}
|
||||
sshPort = Integer.parseInt(host.substring(c + 1));
|
||||
} else {
|
||||
sshHost = host;
|
||||
sshPort = 22;
|
||||
}
|
||||
|
||||
return String.format(" scp -p -P %d %s@%s:hooks/commit-msg .git/hooks/",
|
||||
sshPort, currentUser.getUserName(), sshHost);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Gerrit URL.
|
||||
*
|
||||
* @return the canonical URL (with any trailing slash removed) if it is
|
||||
* configured, otherwise fall back to "http://hostname" where hostname
|
||||
* is the value returned by {@link #getGerritHost()}.
|
||||
*/
|
||||
private static String getGerritUrl(String canonicalWebUrl) {
|
||||
if (canonicalWebUrl != null) {
|
||||
if (canonicalWebUrl.endsWith("/")) {
|
||||
return canonicalWebUrl.substring(0, canonicalWebUrl.lastIndexOf("/"));
|
||||
}
|
||||
return canonicalWebUrl;
|
||||
} else {
|
||||
return "http://" + getGerritHost(canonicalWebUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Gerrit hostname.
|
||||
*
|
||||
* @return the hostname from the canonical URL if it is configured, otherwise
|
||||
* whatever the OS says the hostname is.
|
||||
*/
|
||||
private static String getGerritHost(String canonicalWebUrl) {
|
||||
String host;
|
||||
if (canonicalWebUrl != null) {
|
||||
try {
|
||||
host = new URL(canonicalWebUrl).getHost();
|
||||
} catch (MalformedURLException e) {
|
||||
host = SystemReader.getInstance().getHostname();
|
||||
}
|
||||
} else {
|
||||
host = SystemReader.getInstance().getHostname();
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
private static void addError(String error,
|
||||
List<CommitValidationMessage> messages) {
|
||||
messages.add(new CommitValidationMessage(error, true));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user