| // Copyright (C) 2008 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.receive; | |
| import static com.google.common.base.MoreObjects.firstNonNull; | |
| import static com.google.common.base.Preconditions.checkState; | |
| import static com.google.common.collect.ImmutableList.toImmutableList; | |
| import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap; | |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; | |
| import static com.google.common.flogger.LazyArgs.lazy; | |
| import static com.google.gerrit.entities.RefNames.REFS_CHANGES; | |
| import static com.google.gerrit.entities.RefNames.isConfigRef; | |
| import static com.google.gerrit.entities.RefNames.isRefsUsersSelf; | |
| import static com.google.gerrit.git.ObjectIds.abbreviateName; | |
| import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag; | |
| import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN; | |
| import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER; | |
| import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP; | |
| import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION; | |
| import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES; | |
| import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN; | |
| import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters; | |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; | |
| import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import static java.util.Objects.requireNonNull; | |
| import static java.util.stream.Collectors.joining; | |
| import static java.util.stream.Collectors.toList; | |
| import static org.eclipse.jgit.lib.Constants.R_HEADS; | |
| import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED; | |
| import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK; | |
| import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT; | |
| import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON; | |
| import com.google.common.base.Joiner; | |
| import com.google.common.base.Splitter; | |
| import com.google.common.base.Strings; | |
| import com.google.common.base.Throwables; | |
| import com.google.common.collect.BiMap; | |
| import com.google.common.collect.HashBiMap; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableListMultimap; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.collect.ImmutableSet; | |
| import com.google.common.collect.ImmutableSetMultimap; | |
| import com.google.common.collect.Iterables; | |
| import com.google.common.collect.LinkedListMultimap; | |
| import com.google.common.collect.ListMultimap; | |
| import com.google.common.collect.Lists; | |
| import com.google.common.collect.Maps; | |
| import com.google.common.collect.MultimapBuilder; | |
| import com.google.common.collect.Sets; | |
| import com.google.common.collect.SortedSetMultimap; | |
| import com.google.common.collect.Streams; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.common.UsedAt; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.entities.BooleanProjectConfig; | |
| import com.google.gerrit.entities.BranchNameKey; | |
| import com.google.gerrit.entities.Change; | |
| import com.google.gerrit.entities.HumanComment; | |
| import com.google.gerrit.entities.LabelType; | |
| import com.google.gerrit.entities.LabelTypes; | |
| import com.google.gerrit.entities.PatchSet; | |
| import com.google.gerrit.entities.PatchSetInfo; | |
| import com.google.gerrit.entities.Project; | |
| import com.google.gerrit.entities.RefNames; | |
| import com.google.gerrit.entities.SubmissionId; | |
| import com.google.gerrit.exceptions.StorageException; | |
| import com.google.gerrit.extensions.api.changes.HashtagsInput; | |
| import com.google.gerrit.extensions.api.changes.NotifyHandling; | |
| import com.google.gerrit.extensions.api.changes.NotifyInfo; | |
| import com.google.gerrit.extensions.api.changes.RecipientType; | |
| import com.google.gerrit.extensions.api.changes.SubmitInput; | |
| import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType; | |
| import com.google.gerrit.extensions.client.GeneralPreferencesInfo; | |
| import com.google.gerrit.extensions.registration.DynamicItem; | |
| import com.google.gerrit.extensions.registration.DynamicMap; | |
| import com.google.gerrit.extensions.registration.DynamicSet; | |
| import com.google.gerrit.extensions.registration.Extension; | |
| import com.google.gerrit.extensions.restapi.AuthException; | |
| import com.google.gerrit.extensions.restapi.BadRequestException; | |
| import com.google.gerrit.extensions.restapi.ResourceConflictException; | |
| import com.google.gerrit.extensions.restapi.RestApiException; | |
| import com.google.gerrit.extensions.restapi.UnprocessableEntityException; | |
| import com.google.gerrit.extensions.validators.CommentForValidation; | |
| import com.google.gerrit.extensions.validators.CommentForValidation.CommentSource; | |
| import com.google.gerrit.extensions.validators.CommentForValidation.CommentType; | |
| import com.google.gerrit.extensions.validators.CommentValidationContext; | |
| import com.google.gerrit.extensions.validators.CommentValidationFailure; | |
| import com.google.gerrit.extensions.validators.CommentValidator; | |
| import com.google.gerrit.metrics.Counter0; | |
| import com.google.gerrit.metrics.Counter3; | |
| import com.google.gerrit.metrics.Description; | |
| import com.google.gerrit.metrics.Field; | |
| import com.google.gerrit.metrics.MetricMaker; | |
| import com.google.gerrit.server.CancellationMetrics; | |
| import com.google.gerrit.server.ChangeUtil; | |
| import com.google.gerrit.server.CreateGroupPermissionSyncer; | |
| import com.google.gerrit.server.DeadlineChecker; | |
| import com.google.gerrit.server.DraftCommentsReader; | |
| import com.google.gerrit.server.IdentifiedUser; | |
| import com.google.gerrit.server.InvalidDeadlineException; | |
| import com.google.gerrit.server.PatchSetUtil; | |
| import com.google.gerrit.server.PublishCommentUtil; | |
| import com.google.gerrit.server.PublishCommentsOp; | |
| import com.google.gerrit.server.RequestInfo; | |
| import com.google.gerrit.server.RequestListener; | |
| import com.google.gerrit.server.account.AccountResolver; | |
| import com.google.gerrit.server.approval.ApprovalsUtil; | |
| import com.google.gerrit.server.cancellation.RequestCancelledException; | |
| import com.google.gerrit.server.cancellation.RequestStateContext; | |
| import com.google.gerrit.server.change.AttentionSetUnchangedOp; | |
| import com.google.gerrit.server.change.ChangeInserter; | |
| import com.google.gerrit.server.change.NotifyResolver; | |
| import com.google.gerrit.server.change.SetHashtagsOp; | |
| import com.google.gerrit.server.change.SetPrivateOp; | |
| import com.google.gerrit.server.change.SetTopicOp; | |
| import com.google.gerrit.server.config.AllProjectsName; | |
| import com.google.gerrit.server.config.GerritServerConfig; | |
| import com.google.gerrit.server.config.PluginConfig; | |
| import com.google.gerrit.server.config.ProjectConfigEntry; | |
| import com.google.gerrit.server.edit.ChangeEdit; | |
| import com.google.gerrit.server.edit.ChangeEditUtil; | |
| import com.google.gerrit.server.git.BanCommit; | |
| import com.google.gerrit.server.git.ChangeReportFormatter; | |
| import com.google.gerrit.server.git.GroupCollector; | |
| import com.google.gerrit.server.git.MergedByPushOp; | |
| import com.google.gerrit.server.git.MultiProgressMonitor; | |
| import com.google.gerrit.server.git.MultiProgressMonitor.Task; | |
| import com.google.gerrit.server.git.ReceivePackInitializer; | |
| import com.google.gerrit.server.git.TagCache; | |
| import com.google.gerrit.server.git.ValidationError; | |
| import com.google.gerrit.server.git.validators.CommentCountValidator; | |
| import com.google.gerrit.server.git.validators.CommentSizeValidator; | |
| import com.google.gerrit.server.git.validators.CommitValidationMessage; | |
| import com.google.gerrit.server.git.validators.RefOperationValidationException; | |
| import com.google.gerrit.server.git.validators.RefOperationValidators; | |
| import com.google.gerrit.server.git.validators.ValidationMessage; | |
| import com.google.gerrit.server.index.change.ChangeIndexer; | |
| import com.google.gerrit.server.logging.Metadata; | |
| import com.google.gerrit.server.logging.PerformanceLogContext; | |
| import com.google.gerrit.server.logging.PerformanceLogger; | |
| import com.google.gerrit.server.logging.RequestId; | |
| import com.google.gerrit.server.logging.TraceContext; | |
| import com.google.gerrit.server.logging.TraceContext.TraceTimer; | |
| import com.google.gerrit.server.mail.MailUtil.MailRecipients; | |
| import com.google.gerrit.server.notedb.ChangeNotes; | |
| import com.google.gerrit.server.notedb.Sequences; | |
| import com.google.gerrit.server.patch.AutoMerger; | |
| import com.google.gerrit.server.patch.PatchSetInfoFactory; | |
| import com.google.gerrit.server.permissions.ChangePermission; | |
| import com.google.gerrit.server.permissions.GlobalPermission; | |
| import com.google.gerrit.server.permissions.PermissionBackend; | |
| import com.google.gerrit.server.permissions.PermissionBackendException; | |
| import com.google.gerrit.server.permissions.PermissionDeniedException; | |
| import com.google.gerrit.server.permissions.ProjectPermission; | |
| import com.google.gerrit.server.permissions.RefPermission; | |
| import com.google.gerrit.server.plugincontext.PluginSetContext; | |
| import com.google.gerrit.server.project.CreateRefControl; | |
| import com.google.gerrit.server.project.NoSuchChangeException; | |
| import com.google.gerrit.server.project.NoSuchProjectException; | |
| import com.google.gerrit.server.project.ProjectCache; | |
| import com.google.gerrit.server.project.ProjectConfig; | |
| import com.google.gerrit.server.project.ProjectState; | |
| import com.google.gerrit.server.query.change.ChangeData; | |
| import com.google.gerrit.server.query.change.InternalChangeQuery; | |
| import com.google.gerrit.server.restapi.change.ReplyAttentionSetUpdates; | |
| import com.google.gerrit.server.submit.MergeOp; | |
| import com.google.gerrit.server.submit.MergeOpRepoManager; | |
| import com.google.gerrit.server.update.BatchUpdate; | |
| import com.google.gerrit.server.update.BatchUpdateOp; | |
| import com.google.gerrit.server.update.ChangeContext; | |
| import com.google.gerrit.server.update.PostUpdateContext; | |
| import com.google.gerrit.server.update.RepoContext; | |
| import com.google.gerrit.server.update.RepoOnlyOp; | |
| import com.google.gerrit.server.update.RetryHelper; | |
| import com.google.gerrit.server.update.SubmissionExecutor; | |
| import com.google.gerrit.server.update.SubmissionListener; | |
| import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission; | |
| import com.google.gerrit.server.update.UpdateException; | |
| import com.google.gerrit.server.update.context.RefUpdateContext; | |
| import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType; | |
| import com.google.gerrit.server.util.LabelVote; | |
| import com.google.gerrit.server.util.MagicBranch; | |
| import com.google.gerrit.server.util.RequestScopePropagator; | |
| import com.google.gerrit.server.util.time.TimeUtil; | |
| import com.google.gerrit.util.cli.CmdLineParser; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Provider; | |
| import com.google.inject.Singleton; | |
| import com.google.inject.assistedinject.Assisted; | |
| import com.google.inject.util.Providers; | |
| import java.io.IOException; | |
| import java.io.StringWriter; | |
| import java.io.UnsupportedEncodingException; | |
| import java.net.URLDecoder; | |
| import java.util.ArrayList; | |
| import java.util.Arrays; | |
| import java.util.Collection; | |
| import java.util.Collections; | |
| import java.util.HashMap; | |
| import java.util.HashSet; | |
| import java.util.Iterator; | |
| import java.util.LinkedHashMap; | |
| import java.util.LinkedHashSet; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Objects; | |
| import java.util.Optional; | |
| import java.util.Queue; | |
| import java.util.Set; | |
| import java.util.TreeSet; | |
| import java.util.concurrent.ConcurrentLinkedQueue; | |
| import java.util.concurrent.ExecutionException; | |
| import java.util.concurrent.Future; | |
| import java.util.stream.Collectors; | |
| import java.util.stream.Stream; | |
| import java.util.stream.StreamSupport; | |
| import org.eclipse.jgit.errors.ConfigInvalidException; | |
| import org.eclipse.jgit.errors.IncorrectObjectTypeException; | |
| import org.eclipse.jgit.errors.MissingObjectException; | |
| import org.eclipse.jgit.lib.Config; | |
| import org.eclipse.jgit.lib.Constants; | |
| import org.eclipse.jgit.lib.ObjectId; | |
| import org.eclipse.jgit.lib.ObjectInserter; | |
| import org.eclipse.jgit.lib.ObjectReader; | |
| import org.eclipse.jgit.lib.PersonIdent; | |
| import org.eclipse.jgit.lib.Ref; | |
| import org.eclipse.jgit.lib.Repository; | |
| import org.eclipse.jgit.notes.NoteMap; | |
| import org.eclipse.jgit.revwalk.FooterLine; | |
| import org.eclipse.jgit.revwalk.RevCommit; | |
| import org.eclipse.jgit.revwalk.RevObject; | |
| import org.eclipse.jgit.revwalk.RevSort; | |
| import org.eclipse.jgit.revwalk.RevWalk; | |
| import org.eclipse.jgit.revwalk.filter.RevFilter; | |
| import org.eclipse.jgit.transport.ReceiveCommand; | |
| import org.eclipse.jgit.transport.ReceivePack; | |
| import org.kohsuke.args4j.CmdLineException; | |
| import org.kohsuke.args4j.Option; | |
| /** | |
| * Receives change upload using the Git receive-pack protocol. | |
| * | |
| * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the | |
| * receive-pack protocol that this is based on allows multiple ref updates to be processed at once. | |
| * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH). It is hard to split | |
| * this class up further, because normal pushes can also result in updates to reviews, through the | |
| * autoclose mechanism. | |
| */ | |
| class ReceiveCommits { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'"; | |
| private static final String CANNOT_DELETE_CONFIG = | |
| "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'"; | |
| private static final String INTERNAL_SERVER_ERROR = "internal server error"; | |
| public static final String DIRECT_PUSH_JUSTIFICATION_OPTION = "push-justification"; | |
| interface Factory { | |
| ReceiveCommits create( | |
| ProjectState projectState, | |
| IdentifiedUser user, | |
| ReceivePack receivePack, | |
| Repository repository, | |
| AllRefsWatcher allRefsWatcher, | |
| MessageSender messageSender); | |
| } | |
| private class ReceivePackMessageSender implements MessageSender { | |
| @Override | |
| public void sendMessage(String what) { | |
| receivePack.sendMessage(what); | |
| } | |
| @Override | |
| public void sendError(String what) { | |
| receivePack.sendError(what); | |
| } | |
| @Override | |
| public void sendBytes(byte[] what) { | |
| sendBytes(what, 0, what.length); | |
| } | |
| @Override | |
| public void sendBytes(byte[] what, int off, int len) { | |
| try { | |
| receivePack.getMessageOutputStream().write(what, off, len); | |
| } catch (IOException e) { | |
| // Ignore write failures (matching JGit behavior). | |
| } | |
| } | |
| @Override | |
| public void flush() { | |
| try { | |
| receivePack.getMessageOutputStream().flush(); | |
| } catch (IOException e) { | |
| // Ignore write failures (matching JGit behavior). | |
| } | |
| } | |
| } | |
| private static RestApiException asRestApiException(Exception e) { | |
| if (e instanceof RestApiException) { | |
| return (RestApiException) e; | |
| } else if ((e instanceof ExecutionException) && (e.getCause() instanceof RestApiException)) { | |
| return (RestApiException) e.getCause(); | |
| } | |
| return RestApiException.wrap("Error inserting change/patchset", e); | |
| } | |
| @Singleton | |
| private static class Metrics { | |
| private final Counter0 psRevisionMissing; | |
| private final Counter3<String, String, String> pushCount; | |
| @Inject | |
| Metrics(MetricMaker metricMaker) { | |
| psRevisionMissing = | |
| metricMaker.newCounter( | |
| "receivecommits/ps_revision_missing", | |
| new Description("errors due to patch set revision missing")); | |
| pushCount = | |
| metricMaker.newCounter( | |
| "receivecommits/push_count", | |
| new Description("number of pushes"), | |
| Field.ofString("kind", (metadataBuilder, fieldValue) -> {}) | |
| .description("The push kind (direct vs. magic).") | |
| .build(), | |
| Field.ofString( | |
| "project", | |
| (metadataBuilder, fieldValue) -> metadataBuilder.projectName(fieldValue)) | |
| .description("The name of the project for which the push is done.") | |
| .build(), | |
| Field.ofString("type", (metadataBuilder, fieldValue) -> {}) | |
| .description( | |
| "The type of the update (CREATE, UPDATE, CREATE/UPDATE," | |
| + " UPDATE_NONFASTFORWARD, DELETE).") | |
| .build()); | |
| } | |
| } | |
| // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up | |
| // somewhat, and kept sorted lexicographically within sections, except where later assignments | |
| // depend on previous ones. | |
| // Injected fields. | |
| private final AccountResolver accountResolver; | |
| private final AllProjectsName allProjectsName; | |
| private final BatchUpdate.Factory batchUpdateFactory; | |
| private final CancellationMetrics cancellationMetrics; | |
| private final ChangeEditUtil editUtil; | |
| private final ChangeIndexer indexer; | |
| private final ChangeInserter.Factory changeInserterFactory; | |
| private final ChangeNotes.Factory notesFactory; | |
| private final ChangeReportFormatter changeFormatter; | |
| private final ChangeUtil changeUtil; | |
| private final CmdLineParser.Factory optionParserFactory; | |
| private final DraftCommentsReader draftCommentsReader; | |
| private final PluginSetContext<CommentValidator> commentValidators; | |
| private final BranchCommitValidator.Factory commitValidatorFactory; | |
| private final Config config; | |
| private final CreateGroupPermissionSyncer createGroupPermissionSyncer; | |
| private final CreateRefControl createRefControl; | |
| private final DeadlineChecker.Factory deadlineCheckerFactory; | |
| private final DynamicMap<ProjectConfigEntry> pluginConfigEntries; | |
| private final DynamicSet<PluginPushOption> pluginPushOptions; | |
| private final PluginSetContext<ReceivePackInitializer> initializers; | |
| private final MergedByPushOp.Factory mergedByPushOpFactory; | |
| private final Metrics metrics; | |
| private final PatchSetInfoFactory patchSetInfoFactory; | |
| private final PatchSetUtil psUtil; | |
| private final DynamicSet<PerformanceLogger> performanceLoggers; | |
| private final PermissionBackend permissionBackend; | |
| private final ProjectCache projectCache; | |
| private final Provider<InternalChangeQuery> queryProvider; | |
| private final Provider<MergeOp> mergeOpProvider; | |
| private final Provider<MergeOpRepoManager> ormProvider; | |
| private final ReceiveConfig receiveConfig; | |
| private final RefOperationValidators.Factory refValidatorsFactory; | |
| private final ReplaceOp.Factory replaceOpFactory; | |
| private final PluginSetContext<RequestListener> requestListeners; | |
| private final PublishCommentsOp.Factory publishCommentsOp; | |
| private final RetryHelper retryHelper; | |
| private final RequestScopePropagator requestScopePropagator; | |
| private final Sequences seq; | |
| private final SetHashtagsOp.Factory hashtagsFactory; | |
| private final SetTopicOp.Factory setTopicFactory; | |
| private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners; | |
| private final TagCache tagCache; | |
| private final ProjectConfig.Factory projectConfigFactory; | |
| private final SetPrivateOp.Factory setPrivateOpFactory; | |
| private final ReplyAttentionSetUpdates replyAttentionSetUpdates; | |
| private final AutoMerger autoMerger; | |
| // Assisted injected fields. | |
| private final ProjectState projectState; | |
| private final IdentifiedUser user; | |
| private final ReceivePack receivePack; | |
| // Immutable fields derived from constructor arguments. | |
| private final boolean allowProjectOwnersToChangeParent; | |
| private final LabelTypes labelTypes; | |
| private final NoteMap rejectCommits; | |
| private final PermissionBackend.ForProject permissions; | |
| private final Project project; | |
| private final Repository repo; | |
| // Collections populated during processing. | |
| private final Queue<ValidationMessage> messages; | |
| /** Multimap of error text to refnames that produced that error. */ | |
| private final ListMultimap<String, String> errors; | |
| private final ListMultimap<String, String> pushOptions; | |
| private final ReceivePackRefCache receivePackRefCache; | |
| private final Map<Change.Id, ReplaceRequest> replaceByChange; | |
| // Other settings populated during processing. | |
| private MagicBranchInput magicBranch; | |
| private boolean newChangeForAllNotInTarget; | |
| private boolean setChangeAsPrivate; | |
| private Optional<NoteDbPushOption> noteDbPushOption; | |
| private Optional<String> tracePushOption; | |
| private MessageSender messageSender; | |
| private ReceiveCommitsResult.Builder result; | |
| private ImmutableMap<String, String> loggingTags; | |
| private ImmutableList<String> transitionalPluginOptions; | |
| /** This object is for single use only. */ | |
| private boolean used; | |
| @Inject | |
| ReceiveCommits( | |
| AccountResolver accountResolver, | |
| AllProjectsName allProjectsName, | |
| BatchUpdate.Factory batchUpdateFactory, | |
| CancellationMetrics cancellationMetrics, | |
| ProjectConfig.Factory projectConfigFactory, | |
| @GerritServerConfig Config config, | |
| ChangeEditUtil editUtil, | |
| ChangeIndexer indexer, | |
| ChangeInserter.Factory changeInserterFactory, | |
| ChangeNotes.Factory notesFactory, | |
| DynamicItem<ChangeReportFormatter> changeFormatterProvider, | |
| ChangeUtil changeUtil, | |
| CmdLineParser.Factory optionParserFactory, | |
| DraftCommentsReader draftCommentsReader, | |
| BranchCommitValidator.Factory commitValidatorFactory, | |
| CreateGroupPermissionSyncer createGroupPermissionSyncer, | |
| CreateRefControl createRefControl, | |
| DeadlineChecker.Factory deadlineCheckerFactory, | |
| DynamicMap<ProjectConfigEntry> pluginConfigEntries, | |
| DynamicSet<PluginPushOption> pluginPushOptions, | |
| PluginSetContext<ReceivePackInitializer> initializers, | |
| PluginSetContext<CommentValidator> commentValidators, | |
| MergedByPushOp.Factory mergedByPushOpFactory, | |
| Metrics metrics, | |
| PatchSetInfoFactory patchSetInfoFactory, | |
| PatchSetUtil psUtil, | |
| DynamicSet<PerformanceLogger> performanceLoggers, | |
| PermissionBackend permissionBackend, | |
| ProjectCache projectCache, | |
| Provider<InternalChangeQuery> queryProvider, | |
| Provider<MergeOp> mergeOpProvider, | |
| Provider<MergeOpRepoManager> ormProvider, | |
| PublishCommentsOp.Factory publishCommentsOp, | |
| ReceiveConfig receiveConfig, | |
| RefOperationValidators.Factory refValidatorsFactory, | |
| ReplaceOp.Factory replaceOpFactory, | |
| PluginSetContext<RequestListener> requestListeners, | |
| RetryHelper retryHelper, | |
| RequestScopePropagator requestScopePropagator, | |
| Sequences seq, | |
| SetHashtagsOp.Factory hashtagsFactory, | |
| SetTopicOp.Factory setTopicFactory, | |
| @SuperprojectUpdateOnSubmission | |
| ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners, | |
| TagCache tagCache, | |
| SetPrivateOp.Factory setPrivateOpFactory, | |
| ReplyAttentionSetUpdates replyAttentionSetUpdates, | |
| AutoMerger autoMerger, | |
| @Assisted ProjectState projectState, | |
| @Assisted IdentifiedUser user, | |
| @Assisted ReceivePack rp, | |
| @Assisted Repository repository, | |
| @Assisted AllRefsWatcher allRefsWatcher, | |
| @Nullable @Assisted MessageSender messageSender) | |
| throws IOException { | |
| // Injected fields. | |
| this.accountResolver = accountResolver; | |
| this.allProjectsName = allProjectsName; | |
| this.batchUpdateFactory = batchUpdateFactory; | |
| this.cancellationMetrics = cancellationMetrics; | |
| this.changeFormatter = changeFormatterProvider.get(); | |
| this.changeUtil = changeUtil; | |
| this.changeInserterFactory = changeInserterFactory; | |
| this.draftCommentsReader = draftCommentsReader; | |
| this.commentValidators = commentValidators; | |
| this.commitValidatorFactory = commitValidatorFactory; | |
| this.config = config; | |
| this.createRefControl = createRefControl; | |
| this.createGroupPermissionSyncer = createGroupPermissionSyncer; | |
| this.deadlineCheckerFactory = deadlineCheckerFactory; | |
| this.editUtil = editUtil; | |
| this.hashtagsFactory = hashtagsFactory; | |
| this.setTopicFactory = setTopicFactory; | |
| this.indexer = indexer; | |
| this.initializers = initializers; | |
| this.mergeOpProvider = mergeOpProvider; | |
| this.mergedByPushOpFactory = mergedByPushOpFactory; | |
| this.notesFactory = notesFactory; | |
| this.optionParserFactory = optionParserFactory; | |
| this.ormProvider = ormProvider; | |
| this.metrics = metrics; | |
| this.patchSetInfoFactory = patchSetInfoFactory; | |
| this.permissionBackend = permissionBackend; | |
| this.pluginConfigEntries = pluginConfigEntries; | |
| this.pluginPushOptions = pluginPushOptions; | |
| this.projectCache = projectCache; | |
| this.psUtil = psUtil; | |
| this.performanceLoggers = performanceLoggers; | |
| this.publishCommentsOp = publishCommentsOp; | |
| this.queryProvider = queryProvider; | |
| this.receiveConfig = receiveConfig; | |
| this.refValidatorsFactory = refValidatorsFactory; | |
| this.replaceOpFactory = replaceOpFactory; | |
| this.requestListeners = requestListeners; | |
| this.retryHelper = retryHelper; | |
| this.requestScopePropagator = requestScopePropagator; | |
| this.seq = seq; | |
| this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners; | |
| this.tagCache = tagCache; | |
| this.projectConfigFactory = projectConfigFactory; | |
| this.setPrivateOpFactory = setPrivateOpFactory; | |
| this.replyAttentionSetUpdates = replyAttentionSetUpdates; | |
| this.autoMerger = autoMerger; | |
| // Assisted injected fields. | |
| this.projectState = projectState; | |
| this.user = user; | |
| this.receivePack = rp; | |
| // This repository instance in unwrapped, while the repository instance in | |
| // receivePack.getRepo() is wrapped in PermissionAwareRepository instance. | |
| this.repo = repository; | |
| // Immutable fields derived from constructor arguments. | |
| project = projectState.getProject(); | |
| labelTypes = projectState.getLabelTypes(); | |
| permissions = permissionBackend.user(user).project(project.getNameKey()); | |
| rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk()); | |
| // Collections populated during processing. | |
| errors = MultimapBuilder.linkedHashKeys().arrayListValues().build(); | |
| messages = new ConcurrentLinkedQueue<>(); | |
| pushOptions = LinkedListMultimap.create(); | |
| replaceByChange = new LinkedHashMap<>(); | |
| used = false; | |
| this.allowProjectOwnersToChangeParent = | |
| config.getBoolean("receive", "allowProjectOwnersToChangeParent", false); | |
| // Other settings populated during processing. | |
| newChangeForAllNotInTarget = | |
| projectState.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET); | |
| // Handles for outputting back over the wire to the end user. | |
| this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender(); | |
| this.result = ReceiveCommitsResult.builder(); | |
| this.loggingTags = ImmutableMap.of(); | |
| // TODO(hiesel): Make this decision implicit once vetted | |
| boolean useRefCache = config.getBoolean("receive", "enableInMemoryRefCache", true); | |
| receivePackRefCache = | |
| useRefCache | |
| ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs()) | |
| : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase()); | |
| this.transitionalPluginOptions = | |
| ImmutableList.copyOf(config.getStringList("plugins", null, "transitionalPushOptions")); | |
| } | |
| void init() { | |
| initializers.runEach(i -> i.init(projectState.getNameKey(), receivePack)); | |
| } | |
| MessageSender getMessageSender() { | |
| return messageSender; | |
| } | |
| Project getProject() { | |
| return project; | |
| } | |
| private void addMessage(String message, ValidationMessage.Type type) { | |
| messages.add(new CommitValidationMessage(message, type)); | |
| } | |
| private void addMessage(String message) { | |
| messages.add(new CommitValidationMessage(message, ValidationMessage.Type.OTHER)); | |
| } | |
| private void addError(String error) { | |
| addMessage(error, ValidationMessage.Type.ERROR); | |
| } | |
| /** | |
| * Sends all messages which have been collected while processing the push to the client. | |
| * | |
| * <p><strong>Attention:</strong>{@link AsyncReceiveCommits} may call this method while {@link | |
| * #processCommands(Collection, MultiProgressMonitor)} is still running (if the execution of | |
| * processCommands takes too long and AsyncReceiveCommits gets a timeout). This means that local | |
| * variables that are accessed in this method must be thread-safe (otherwise we may hit a {@link | |
| * java.util.ConcurrentModificationException} if we read a variable here that at the same time is | |
| * updated by the background thread that still executes processCommands). | |
| */ | |
| void sendMessages() { | |
| try (TraceContext traceContext = | |
| TraceContext.newTrace( | |
| loggingTags.containsKey(RequestId.Type.TRACE_ID.name()), | |
| loggingTags.get(RequestId.Type.TRACE_ID.name()), | |
| (tagName, traceId) -> {})) { | |
| loggingTags.forEach((tagName, tagValue) -> traceContext.addTag(tagName, tagValue)); | |
| for (ValidationMessage m : messages) { | |
| String msg = m.getType().getPrefix() + m.getMessage(); | |
| logger.atFine().log("Sending message: %s", msg); | |
| // Avoid calling sendError which will add its own error: prefix. | |
| messageSender.sendMessage(msg); | |
| } | |
| } | |
| } | |
| ReceiveCommitsResult processCommands( | |
| Collection<ReceiveCommand> commands, MultiProgressMonitor progress) throws StorageException { | |
| checkState(!used, "Tried to re-use a ReceiveCommits objects that is single-use only"); | |
| long start = TimeUtil.nowNanos(); | |
| parsePushOptions(); | |
| String clientProvidedDeadlineValue = | |
| Iterables.getLast(pushOptions.get("deadline"), /* defaultValue= */ null); | |
| int commandCount = commands.size(); | |
| try (TraceContext traceContext = | |
| TraceContext.newTrace( | |
| tracePushOption.isPresent(), | |
| tracePushOption.orElse(null), | |
| (tagName, traceId) -> addMessage(tagName + ": " + traceId)); | |
| PerformanceLogContext performanceLogContext = | |
| new PerformanceLogContext(config, performanceLoggers); | |
| TraceTimer traceTimer = | |
| newTimer("processCommands", Metadata.builder().resourceCount(commandCount))) { | |
| RequestInfo requestInfo = | |
| RequestInfo.builder(RequestInfo.RequestType.GIT_RECEIVE, user, traceContext) | |
| .project(project.getNameKey()) | |
| .build(); | |
| requestListeners.runEach(l -> l.onRequest(requestInfo)); | |
| traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get())); | |
| // Log the push options here, rather than in parsePushOptions(), so that they are included | |
| // into the trace if tracing is enabled. | |
| logger.atFine().log("push options: %s", receivePack.getPushOptions()); | |
| Task commandProgress = progress.beginSubTask("refs", UNKNOWN); | |
| commands = | |
| commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList()); | |
| try (RequestStateContext requestStateContext = | |
| RequestStateContext.open() | |
| .addRequestStateProvider(progress) | |
| .addRequestStateProvider( | |
| deadlineCheckerFactory.create(start, requestInfo, clientProvidedDeadlineValue))) { | |
| processCommandsUnsafe(commands, progress); | |
| rejectRemaining(commands, INTERNAL_SERVER_ERROR); | |
| } catch (InvalidDeadlineException e) { | |
| rejectRemaining(commands, e.getMessage()); | |
| } catch (RuntimeException e) { | |
| Optional<RequestCancelledException> requestCancelledException = | |
| RequestCancelledException.getFromCausalChain(e); | |
| if (!requestCancelledException.isPresent()) { | |
| Throwables.throwIfUnchecked(e); | |
| } | |
| cancellationMetrics.countCancelledRequest( | |
| requestInfo, requestCancelledException.get().getCancellationReason()); | |
| StringBuilder msg = | |
| new StringBuilder(requestCancelledException.get().formatCancellationReason()); | |
| if (requestCancelledException.get().getCancellationMessage().isPresent()) { | |
| msg.append( | |
| String.format( | |
| " (%s)", requestCancelledException.get().getCancellationMessage().get())); | |
| } | |
| rejectRemaining(commands, msg.toString()); | |
| } | |
| // This sends error messages before the 'done' string of the progress monitor is sent. | |
| // Currently, the test framework relies on this ordering to understand if pushes completed | |
| // successfully. | |
| sendErrorMessages(); | |
| commandProgress.end(); | |
| loggingTags = traceContext.getTags(); | |
| logger.atFine().log("Processing commands done."); | |
| } | |
| progress.end(); | |
| return result.build(); | |
| } | |
| // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED. | |
| private void processCommandsUnsafe( | |
| Collection<ReceiveCommand> commands, MultiProgressMonitor progress) { | |
| logger.atFine().log("Calling user: %s, commands: %d", user.getLoggableName(), commands.size()); | |
| // If the list of groups is large, the log entry may get dropped, so separate out. | |
| logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups())); | |
| if (!projectState.getProject().getState().permitsWrite()) { | |
| for (ReceiveCommand cmd : commands) { | |
| reject(cmd, "prohibited by Gerrit: project state does not permit write"); | |
| } | |
| return; | |
| } | |
| List<ReceiveCommand> magicCommands = new ArrayList<>(); | |
| List<ReceiveCommand> regularCommands = new ArrayList<>(); | |
| for (ReceiveCommand cmd : commands) { | |
| if (MagicBranch.isMagicBranch(cmd.getRefName())) { | |
| magicCommands.add(cmd); | |
| } else { | |
| regularCommands.add(cmd); | |
| } | |
| } | |
| if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) { | |
| rejectRemaining(commands, "cannot combine normal pushes and magic pushes"); | |
| return; | |
| } | |
| try { | |
| if (!magicCommands.isEmpty()) { | |
| parseMagicBranch(Iterables.getLast(magicCommands)); | |
| // Using the submit option submits the created change(s) immediately without checking labels | |
| // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that | |
| // code review is being done. | |
| String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic"; | |
| metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands)); | |
| } | |
| Optional<String> justification = | |
| pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst(); | |
| try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(justification)) { | |
| if (!regularCommands.isEmpty()) { | |
| metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands)); | |
| } | |
| if (!regularCommands.isEmpty()) { | |
| handleRegularCommands(regularCommands, progress); | |
| return; | |
| } | |
| } | |
| boolean first = true; | |
| for (ReceiveCommand cmd : magicCommands) { | |
| if (first) { | |
| first = false; | |
| } else { | |
| reject(cmd, "duplicate request"); | |
| } | |
| } | |
| } catch (PermissionBackendException | NoSuchProjectException | IOException err) { | |
| logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName()); | |
| return; | |
| } | |
| Task newProgress = progress.beginSubTask("new", UNKNOWN); | |
| Task replaceProgress = progress.beginSubTask("updated", UNKNOWN); | |
| ImmutableList<CreateRequest> newChanges = ImmutableList.of(); | |
| try { | |
| if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) { | |
| try { | |
| newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress); | |
| } catch (IOException e) { | |
| throw new StorageException("Failed to select new changes in " + project.getName(), e); | |
| } | |
| } | |
| // Commit validation has already happened, so any changes without Change-Id are for the | |
| // deprecated feature. | |
| warnAboutMissingChangeId(newChanges); | |
| preparePatchSetsForReplace(newChanges); | |
| insertChangesAndPatchSets(newChanges, replaceProgress); | |
| } finally { | |
| newProgress.end(); | |
| replaceProgress.end(); | |
| } | |
| queueSuccessMessages(newChanges); | |
| logger.atFine().log( | |
| "Command results: %s", | |
| lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(",")))); | |
| } | |
| private String getUpdateType(List<ReceiveCommand> commands) { | |
| return commands.stream() | |
| .map(ReceiveCommand::getType) | |
| .map(ReceiveCommand.Type::name) | |
| .distinct() | |
| .sorted() | |
| .collect(joining("/")); | |
| } | |
| private void sendErrorMessages() { | |
| if (!errors.isEmpty()) { | |
| logger.atFine().log("Handling error conditions: %s", errors.keySet()); | |
| for (String error : errors.keySet()) { | |
| receivePack.sendMessage("error: " + buildError(error, errors.get(error))); | |
| } | |
| receivePack.sendMessage(String.format("User: %s", user.getLoggableName())); | |
| receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER); | |
| } | |
| } | |
| private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress) | |
| throws PermissionBackendException, IOException, NoSuchProjectException { | |
| try (TraceTimer traceTimer = | |
| newTimer("handleRegularCommands", Metadata.builder().resourceCount(cmds.size()))) { | |
| result.magicPush(false); | |
| for (ReceiveCommand cmd : cmds) { | |
| parseRegularCommand(cmd); | |
| } | |
| Map<BranchNameKey, ReceiveCommand> branches; | |
| try (BatchUpdate bu = | |
| batchUpdateFactory.create( | |
| project.getNameKey(), user.materializedCopy(), TimeUtil.now()); | |
| ObjectInserter ins = repo.newObjectInserter(); | |
| ObjectReader reader = ins.newReader(); | |
| RevWalk rw = new RevWalk(reader); | |
| MergeOpRepoManager orm = ormProvider.get()) { | |
| bu.setRepository(repo, rw, ins); | |
| bu.setRefLogMessage("push"); | |
| int added = 0; | |
| for (ReceiveCommand cmd : cmds) { | |
| if (cmd.getResult() == NOT_ATTEMPTED) { | |
| bu.addRepoOnlyOp(new UpdateOneRefOp(cmd)); | |
| added++; | |
| } | |
| } | |
| logger.atFine().log("Added %d additional ref updates", added); | |
| SubmissionExecutor submissionExecutor = | |
| new SubmissionExecutor(false, superprojectUpdateSubmissionListeners); | |
| submissionExecutor.execute(ImmutableList.of(bu)); | |
| orm.setContext(TimeUtil.now(), user, NotifyResolver.Result.none()); | |
| submissionExecutor.afterExecutions(orm); | |
| branches = bu.getSuccessfullyUpdatedBranches(false); | |
| } catch (UpdateException | RestApiException e) { | |
| throw new StorageException(e); | |
| } | |
| // This could be moved into a SubmissionListener | |
| branches.values().stream() | |
| .filter(c -> isHead(c) || isConfig(c)) | |
| .forEach( | |
| c -> { | |
| // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps | |
| // that should happen in this loops are things that can't happen within one | |
| // BatchUpdate because they involve kicking off an additional BatchUpdate. | |
| switch (c.getType()) { | |
| case CREATE: | |
| case UPDATE: | |
| case UPDATE_NONFASTFORWARD: | |
| Task closeProgress = progress.beginSubTask("closed", UNKNOWN); | |
| try (RefUpdateContext ctx = | |
| RefUpdateContext.open(RefUpdateType.AUTO_CLOSE_CHANGES)) { | |
| autoCloseChanges(c, closeProgress); | |
| } | |
| closeProgress.end(); | |
| break; | |
| case DELETE: | |
| break; | |
| } | |
| }); | |
| } | |
| } | |
| /** Appends messages for successful change creation/updates. */ | |
| private void queueSuccessMessages(List<CreateRequest> newChanges) { | |
| // adjacency list for commit => parent | |
| Map<String, String> adjList = new HashMap<>(); | |
| for (CreateRequest cr : newChanges) { | |
| String parent = cr.commit.getParentCount() == 0 ? null : cr.commit.getParent(0).name(); | |
| adjList.put(cr.commit.name(), parent); | |
| } | |
| for (ReplaceRequest rr : replaceByChange.values()) { | |
| String parent = null; | |
| if (rr.revCommit != null) { | |
| parent = rr.revCommit.getParentCount() == 0 ? null : rr.revCommit.getParent(0).name(); | |
| } | |
| adjList.put(rr.newCommitId.name(), parent); | |
| } | |
| // get commits that are not parents | |
| Set<String> leafs = new TreeSet<>(adjList.keySet()); | |
| leafs.removeAll(adjList.values()); | |
| // go backwards from the last commit to its parent(s) | |
| Set<String> ordered = new LinkedHashSet<>(); | |
| for (String leaf : leafs) { | |
| if (ordered.contains(leaf)) { | |
| continue; | |
| } | |
| while (leaf != null) { | |
| if (!ordered.contains(leaf)) { | |
| ordered.add(leaf); | |
| } | |
| leaf = adjList.get(leaf); | |
| } | |
| } | |
| // reverse the order to start with earliest commit | |
| List<String> orderedCommits = new ArrayList<>(ordered); | |
| Collections.reverse(orderedCommits); | |
| Map<String, CreateRequest> created = | |
| newChanges.stream() | |
| .filter(r -> r.change != null) | |
| .collect(Collectors.toMap(r -> r.commit.name(), r -> r)); | |
| Map<String, ReplaceRequest> updated = | |
| replaceByChange.values().stream() | |
| .filter(r -> r.inputCommand.getResult() == OK) | |
| .collect(Collectors.toMap(r -> r.newCommitId.name(), r -> r)); | |
| if (created.isEmpty() && updated.isEmpty()) { | |
| return; | |
| } | |
| addMessage(""); | |
| addMessage("SUCCESS"); | |
| addMessage(""); | |
| boolean edit = false; | |
| Boolean isPrivate = null; | |
| Boolean wip = null; | |
| if (!updated.isEmpty()) { | |
| edit = magicBranch != null && magicBranch.edit; | |
| if (magicBranch != null) { | |
| if (magicBranch.isPrivate) { | |
| isPrivate = true; | |
| } else if (magicBranch.removePrivate) { | |
| isPrivate = false; | |
| } | |
| if (magicBranch.workInProgress) { | |
| wip = true; | |
| } else if (magicBranch.ready) { | |
| wip = false; | |
| } | |
| } | |
| } | |
| for (String commit : orderedCommits) { | |
| if (created.get(commit) != null) { | |
| addCreatedMessage(created.get(commit)); | |
| } else if (updated.get(commit) != null) { | |
| addReplacedMessage(updated.get(commit), edit, isPrivate, wip); | |
| } | |
| } | |
| addMessage(""); | |
| } | |
| private void addCreatedMessage(CreateRequest c) { | |
| addMessage( | |
| changeFormatter.newChange( | |
| ChangeReportFormatter.Input.builder().setChange(c.change).build())); | |
| } | |
| private void addReplacedMessage(ReplaceRequest u, boolean edit, Boolean isPrivate, Boolean wip) { | |
| String subject; | |
| if (edit) { | |
| subject = | |
| u.revCommit == null ? u.notes.getChange().getSubject() : u.revCommit.getShortMessage(); | |
| } else { | |
| subject = u.info.getSubject(); | |
| } | |
| if (isPrivate == null) { | |
| isPrivate = u.notes.getChange().isPrivate(); | |
| } | |
| if (wip == null) { | |
| wip = u.notes.getChange().isWorkInProgress(); | |
| } | |
| ChangeReportFormatter.Input input = | |
| ChangeReportFormatter.Input.builder() | |
| .setChange(u.notes.getChange()) | |
| .setSubject(subject) | |
| .setIsEdit(edit) | |
| .setIsPrivate(isPrivate) | |
| .setIsWorkInProgress(wip) | |
| .build(); | |
| addMessage(changeFormatter.changeUpdated(input)); | |
| u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg + "\n").ifPresent(this::addMessage); | |
| } | |
| private void insertChangesAndPatchSets( | |
| ImmutableList<CreateRequest> newChanges, Task replaceProgress) { | |
| try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { | |
| try (TraceTimer traceTimer = | |
| newTimer( | |
| "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) { | |
| ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null; | |
| if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) { | |
| logger.atWarning().log( | |
| "Skipping change updates on %s because ref update failed: %s %s", | |
| project.getName(), | |
| magicBranchCmd.getResult(), | |
| Strings.nullToEmpty(magicBranchCmd.getMessage())); | |
| return; | |
| } | |
| try { | |
| if (!newChanges.isEmpty()) { | |
| // TODO: Retry lock failures on new change insertions. The retry will | |
| // likely have to move to a higher layer to be able to achieve that | |
| // due to state that needs to be reset with each retry attempt. | |
| insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress); | |
| } else { | |
| retryHelper | |
| .changeUpdate( | |
| "insertPatchSets", | |
| updateFactory -> { | |
| insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress); | |
| return null; | |
| }) | |
| .defaultTimeoutMultiplier(5) | |
| .call(); | |
| } | |
| } catch (ResourceConflictException e) { | |
| addError(e.getMessage()); | |
| reject(magicBranchCmd, "conflict"); | |
| } catch (BadRequestException | UnprocessableEntityException | AuthException e) { | |
| logger.atFine().withCause(e).log("Rejecting due to client error"); | |
| reject(magicBranchCmd, e.getMessage()); | |
| } catch (RestApiException | IOException | UpdateException e) { | |
| throw new StorageException("Can't insert change/patch set for " + project.getName(), e); | |
| } | |
| if (magicBranch != null && magicBranch.submit) { | |
| try { | |
| submit(newChanges, replaceByChange.values()); | |
| } catch (ResourceConflictException e) { | |
| addError(e.getMessage()); | |
| reject(magicBranchCmd, "conflict"); | |
| } catch (RestApiException | |
| | StorageException | |
| | UpdateException | |
| | IOException | |
| | ConfigInvalidException | |
| | PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName()); | |
| reject(magicBranchCmd, "error during submit"); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private void insertChangesAndPatchSets( | |
| ReceiveCommand magicBranchCmd, List<CreateRequest> newChanges, Task replaceProgress) | |
| throws RestApiException, IOException { | |
| try (BatchUpdate bu = | |
| batchUpdateFactory.create( | |
| project.getNameKey(), user.materializedCopy(), TimeUtil.now()); | |
| ObjectInserter ins = repo.newObjectInserter(); | |
| ObjectReader reader = ins.newReader(); | |
| RevWalk rw = new RevWalk(reader)) { | |
| bu.setRepository(repo, rw, ins); | |
| bu.setRefLogMessage("push"); | |
| if (magicBranch != null) { | |
| bu.setNotify(magicBranch.getNotifyForNewChange()); | |
| } | |
| logger.atFine().log("Adding %d replace requests", newChanges.size()); | |
| for (ReplaceRequest replace : replaceByChange.values()) { | |
| replace.addOps(bu, replaceProgress); | |
| if (magicBranch != null) { | |
| bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes)); | |
| if (magicBranch.shouldPublishComments()) { | |
| bu.addOp( | |
| replace.notes.getChangeId(), | |
| publishCommentsOp.create(replace.psId, project.getNameKey())); | |
| Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId()); | |
| if (!changeNotes.isPresent()) { | |
| // If not present, no need to update attention set here since this is | |
| // a new change. | |
| continue; | |
| } | |
| List<HumanComment> drafts = | |
| draftCommentsReader.getDraftsByChangeAndDraftAuthor( | |
| changeNotes.get(), user.getAccountId()); | |
| if (drafts.isEmpty()) { | |
| // If no comments, attention set shouldn't update since the user | |
| // didn't reply. | |
| continue; | |
| } | |
| replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply( | |
| bu, changeNotes.get(), isReadyForReview(changeNotes.get()), user, drafts); | |
| } | |
| } | |
| } | |
| logger.atFine().log("Adding %d create requests", newChanges.size()); | |
| for (CreateRequest create : newChanges) { | |
| create.addOps(bu); | |
| } | |
| logger.atFine().log("Executing batch"); | |
| try { | |
| bu.execute(); | |
| } catch (UpdateException e) { | |
| throw asRestApiException(e); | |
| } | |
| replaceByChange.values().stream() | |
| .forEach( | |
| req -> result.addChange(ReceiveCommitsResult.ChangeStatus.REPLACED, req.ontoChange)); | |
| newChanges.stream() | |
| .forEach( | |
| req -> result.addChange(ReceiveCommitsResult.ChangeStatus.CREATED, req.changeId)); | |
| if (magicBranchCmd != null) { | |
| magicBranchCmd.setResult(OK); | |
| } | |
| for (ReplaceRequest replace : replaceByChange.values()) { | |
| String rejectMessage = replace.getRejectMessage(); | |
| if (rejectMessage == null) { | |
| if (replace.inputCommand.getResult() == NOT_ATTEMPTED) { | |
| // Not necessarily the magic branch, so need to set OK on the original | |
| // value. | |
| replace.inputCommand.setResult(OK); | |
| } | |
| } else { | |
| logger.atFine().log("Rejecting due to message from ReplaceOp"); | |
| reject(replace.inputCommand, rejectMessage); | |
| } | |
| } | |
| } | |
| } | |
| private boolean isReadyForReview(ChangeNotes changeNotes) { | |
| return (!changeNotes.getChange().isWorkInProgress() && !magicBranch.workInProgress) | |
| || magicBranch.ready; | |
| } | |
| private String buildError(String error, List<String> branches) { | |
| StringBuilder sb = new StringBuilder(); | |
| if (branches.size() == 1) { | |
| String branch = branches.get(0); | |
| sb.append("branch ").append(branch).append(":\n"); | |
| // As of 2020, there are still many git-review <1.27 installations in the wild. | |
| // These users will see failures as their old git-review assumes that | |
| // `refs/publish/...` is still magic, which it isn't. As Gerrit's default error messages are | |
| // misleading for these users, we hint them at upgrading their git-review. | |
| if (branch.startsWith("refs/publish/")) { | |
| sb.append("If you are using git-review, update to at least git-review 1.27. Otherwise:\n"); | |
| } | |
| sb.append(error); | |
| return sb.toString(); | |
| } | |
| sb.append("branches ").append(Joiner.on(", ").join(branches)); | |
| return sb.append(":\n").append(error).toString(); | |
| } | |
| /** Parses push options specified as "git push -o OPTION" */ | |
| private void parsePushOptions() { | |
| List<String> optionList = receivePack.getPushOptions(); | |
| if (optionList != null) { | |
| for (String option : optionList) { | |
| int e = option.indexOf('='); | |
| if (e > 0) { | |
| pushOptions.put(option.substring(0, e), option.substring(e + 1)); | |
| } else { | |
| pushOptions.put(option, ""); | |
| } | |
| } | |
| } | |
| List<String> noteDbValues = pushOptions.get("notedb"); | |
| if (!noteDbValues.isEmpty()) { | |
| // These semantics for duplicates/errors are somewhat arbitrary and may not match e.g. the | |
| // CmdLineParser behavior used by MagicBranchInput. | |
| String value = Iterables.getLast(noteDbValues); | |
| noteDbPushOption = NoteDbPushOption.parse(value); | |
| if (!noteDbPushOption.isPresent()) { | |
| addError("Invalid value in -o " + NoteDbPushOption.OPTION_NAME + "=" + value); | |
| } | |
| } else { | |
| noteDbPushOption = Optional.of(NoteDbPushOption.DISALLOW); | |
| } | |
| List<String> traceValues = pushOptions.get("trace"); | |
| if (!traceValues.isEmpty()) { | |
| tracePushOption = Optional.of(Iterables.getLast(traceValues)); | |
| } else { | |
| tracePushOption = Optional.empty(); | |
| } | |
| } | |
| // Wrap ReceiveCommand so the progress counter works automatically. | |
| private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) { | |
| String refname = cmd.getRefName(); | |
| if (isRefsUsersSelf(cmd.getRefName(), projectState.isAllUsers())) { | |
| refname = RefNames.refsUsers(user.getAccountId()); | |
| logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname); | |
| } | |
| // We must also update the original, because callers may inspect it afterwards to decide if | |
| // the command went through or not. | |
| return new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), refname, cmd.getType()) { | |
| @Override | |
| public void setResult(Result s, String m) { | |
| if (getResult() == NOT_ATTEMPTED) { // Only report the progress update once. | |
| progress.update(1); | |
| } | |
| // Counter intuitively, we don't check that results == NOT_ATTEMPTED here. | |
| // This is so submit-on-push can still reject the update if the change is created | |
| // successfully | |
| // (status OK) but the submit failed (merge failed: REJECTED_OTHER_REASON). | |
| super.setResult(s, m); | |
| cmd.setResult(s, m); | |
| } | |
| }; | |
| } | |
| /* | |
| * Interpret a normal push. | |
| */ | |
| private void parseRegularCommand(ReceiveCommand cmd) | |
| throws PermissionBackendException, NoSuchProjectException, IOException { | |
| try (TraceTimer traceTimer = newTimer("parseRegularCommand")) { | |
| if (cmd.getResult() != NOT_ATTEMPTED) { | |
| // Already rejected by the core receive process. | |
| logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd); | |
| return; | |
| } | |
| if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) { | |
| reject(cmd, "not valid ref"); | |
| return; | |
| } | |
| if (RefNames.isNoteDbMetaRef(cmd.getRefName())) { | |
| // Reject pushes to NoteDb refs without a special option and permission. Note that this | |
| // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will | |
| // migrate to NoteDb eventually, and we don't want garbage data waiting there when the | |
| // migration finishes. | |
| logger.atFine().log( | |
| "%s NoteDb ref %s with %s=%s", | |
| cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption); | |
| if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) { | |
| // Only reject this command, not the whole push. This supports the use case of "git clone | |
| // --mirror" followed by "git push --mirror", when the user doesn't really intend to clone | |
| // or mirror the NoteDb data; there is no single refspec that describes all refs *except* | |
| // NoteDb refs. | |
| reject( | |
| cmd, | |
| "NoteDb update requires -o " | |
| + NoteDbPushOption.OPTION_NAME | |
| + "=" | |
| + NoteDbPushOption.ALLOW.value()); | |
| return; | |
| } | |
| if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) { | |
| reject(cmd, "NoteDb update requires access database permission"); | |
| return; | |
| } | |
| } | |
| switch (cmd.getType()) { | |
| case CREATE: | |
| parseCreate(cmd); | |
| break; | |
| case UPDATE: | |
| parseUpdate(cmd); | |
| break; | |
| case DELETE: | |
| parseDelete(cmd); | |
| break; | |
| case UPDATE_NONFASTFORWARD: | |
| parseRewind(cmd); | |
| break; | |
| default: | |
| reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType()); | |
| return; | |
| } | |
| if (cmd.getResult() != NOT_ATTEMPTED) { | |
| return; | |
| } | |
| if (isConfig(cmd)) { | |
| validateConfigPush(cmd); | |
| } | |
| } | |
| } | |
| /** Validates a push to refs/meta/config, and reject the command if it fails. */ | |
| private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("validateConfigPush")) { | |
| logger.atFine().log("Processing %s command", cmd.getRefName()); | |
| if (!permissions.test(ProjectPermission.WRITE_CONFIG)) { | |
| reject( | |
| cmd, | |
| String.format( | |
| "must be either project owner or have %s permission", | |
| ProjectPermission.WRITE_CONFIG.describeForException())); | |
| return; | |
| } | |
| switch (cmd.getType()) { | |
| case CREATE: | |
| case UPDATE: | |
| case UPDATE_NONFASTFORWARD: | |
| try { | |
| ProjectConfig cfg = projectConfigFactory.create(project.getNameKey()); | |
| cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId()); | |
| if (!cfg.getValidationErrors().isEmpty()) { | |
| addError("Invalid project configuration:"); | |
| for (ValidationError err : cfg.getValidationErrors()) { | |
| addError(" " + err.getMessage()); | |
| } | |
| reject(cmd, "invalid project configuration"); | |
| logger.atSevere().log( | |
| "User %s tried to push invalid project configuration %s for %s", | |
| user.getLoggableName(), cmd.getNewId().name(), project.getName()); | |
| return; | |
| } | |
| Project.NameKey newParent = cfg.getProject().getParent(allProjectsName); | |
| Project.NameKey oldParent = project.getParent(allProjectsName); | |
| if (oldParent == null) { | |
| // update of the 'All-Projects' project | |
| if (newParent != null) { | |
| reject(cmd, "invalid project configuration: root project cannot have parent"); | |
| return; | |
| } | |
| } else { | |
| if (!oldParent.equals(newParent)) { | |
| if (allowProjectOwnersToChangeParent) { | |
| if (!permissionBackend | |
| .user(user) | |
| .project(project.getNameKey()) | |
| .test(ProjectPermission.WRITE_CONFIG)) { | |
| reject( | |
| cmd, "invalid project configuration: only project owners can set parent"); | |
| return; | |
| } | |
| } else { | |
| if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) { | |
| reject(cmd, "invalid project configuration: only Gerrit admin can set parent"); | |
| return; | |
| } | |
| } | |
| } | |
| if (!projectCache.get(newParent).isPresent()) { | |
| reject(cmd, "invalid project configuration: parent does not exist"); | |
| return; | |
| } | |
| } | |
| validatePluginConfig(cmd, cfg); | |
| } catch (Exception e) { | |
| reject(cmd, "invalid project configuration"); | |
| logger.atSevere().withCause(e).log( | |
| "User %s tried to push invalid project configuration %s for %s", | |
| user.getLoggableName(), cmd.getNewId().name(), project.getName()); | |
| return; | |
| } | |
| break; | |
| case DELETE: | |
| break; | |
| default: | |
| reject( | |
| cmd, | |
| "prohibited by Gerrit: don't know how to handle config update of type " | |
| + cmd.getType()); | |
| } | |
| } | |
| } | |
| /** | |
| * validates a push to refs/meta/config for plugin configuration, and rejects the push if it | |
| * fails. | |
| */ | |
| private void validatePluginConfig(ReceiveCommand cmd, ProjectConfig cfg) { | |
| for (Extension<ProjectConfigEntry> e : pluginConfigEntries) { | |
| PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName()); | |
| ProjectConfigEntry configEntry = e.getProvider().get(); | |
| String value = pluginCfg.getString(e.getExportName()); | |
| String oldValue = | |
| projectState.getPluginConfig(e.getPluginName()).getString(e.getExportName()); | |
| if (configEntry.getType() == ProjectConfigEntryType.ARRAY) { | |
| oldValue = | |
| Arrays.stream( | |
| projectState | |
| .getPluginConfig(e.getPluginName()) | |
| .getStringList(e.getExportName())) | |
| .collect(joining("\n")); | |
| } | |
| if ((value == null ? oldValue != null : !value.equals(oldValue)) | |
| && !configEntry.isEditable(projectState)) { | |
| reject( | |
| cmd, | |
| String.format( | |
| "invalid project configuration: Not allowed to set parameter" | |
| + " '%s' of plugin '%s' on project '%s'.", | |
| e.getExportName(), e.getPluginName(), project.getName())); | |
| continue; | |
| } | |
| if (ProjectConfigEntryType.LIST.equals(configEntry.getType()) | |
| && value != null | |
| && !configEntry.getPermittedValues().contains(value)) { | |
| reject( | |
| cmd, | |
| String.format( | |
| "invalid project configuration: The value '%s' is " | |
| + "not permitted for parameter '%s' of plugin '%s'.", | |
| value, e.getExportName(), e.getPluginName())); | |
| } | |
| } | |
| } | |
| private void parseCreate(ReceiveCommand cmd) | |
| throws PermissionBackendException, NoSuchProjectException, IOException { | |
| try (TraceTimer traceTimer = newTimer("parseCreate")) { | |
| if (repo.resolve(cmd.getRefName()) != null) { | |
| reject( | |
| cmd, | |
| String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName())); | |
| return; | |
| } | |
| RevObject obj; | |
| try { | |
| obj = receivePack.getRevWalk().parseAny(cmd.getNewId()); | |
| } catch (IOException e) { | |
| throw new StorageException( | |
| String.format( | |
| "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()), | |
| e); | |
| } | |
| logger.atFine().log("Creating %s", cmd); | |
| if (isHead(cmd) && !isCommit(cmd)) { | |
| return; | |
| } | |
| BranchNameKey branch = BranchNameKey.create(project.getName(), cmd.getRefName()); | |
| try { | |
| // Must pass explicit user instead of injecting a provider into CreateRefControl, since | |
| // Provider<CurrentUser> within ReceiveCommits will always return anonymous. | |
| createRefControl.checkCreateRef( | |
| Providers.of(user), receivePack.getRepository(), branch, obj, /* forPush= */ true); | |
| } catch (AuthException denied) { | |
| rejectProhibited(cmd, denied); | |
| return; | |
| } catch (ResourceConflictException denied) { | |
| reject(cmd, "prohibited by Gerrit: " + denied.getMessage()); | |
| return; | |
| } | |
| if (validRefOperation(cmd)) { | |
| validateRegularPushCommits( | |
| BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd); | |
| } | |
| } | |
| } | |
| private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException { | |
| try (TraceTimer traceTimer = TraceContext.newTimer("parseUpdate")) { | |
| logger.atFine().log("Updating %s", cmd); | |
| Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE); | |
| if (!err.isPresent()) { | |
| if (isHead(cmd) && !isCommit(cmd)) { | |
| reject(cmd, "head must point to commit"); | |
| return; | |
| } | |
| if (validRefOperation(cmd)) { | |
| validateRegularPushCommits( | |
| BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd); | |
| } | |
| } else { | |
| rejectProhibited(cmd, err.get()); | |
| } | |
| } | |
| } | |
| private boolean isCommit(ReceiveCommand cmd) { | |
| RevObject obj; | |
| try { | |
| obj = receivePack.getRevWalk().parseAny(cmd.getNewId()); | |
| } catch (IOException e) { | |
| throw new StorageException( | |
| String.format( | |
| "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()), | |
| e); | |
| } | |
| if (obj instanceof RevCommit) { | |
| return true; | |
| } | |
| reject(cmd, "not a commit"); | |
| return false; | |
| } | |
| private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("parseDelete")) { | |
| logger.atFine().log("Deleting %s", cmd); | |
| if (cmd.getRefName().startsWith(REFS_CHANGES)) { | |
| errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName()); | |
| reject(cmd, "cannot delete changes"); | |
| } else if (isConfigRef(cmd.getRefName())) { | |
| errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName()); | |
| reject(cmd, "cannot delete project configuration"); | |
| } | |
| Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE); | |
| if (!err.isPresent()) { | |
| validRefOperation(cmd); | |
| } else { | |
| rejectProhibited(cmd, err.get()); | |
| } | |
| } | |
| } | |
| private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("parseRewind")) { | |
| try { | |
| receivePack.getRevWalk().parseCommit(cmd.getNewId()); | |
| } catch (IOException e) { | |
| throw new StorageException( | |
| String.format( | |
| "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()), | |
| e); | |
| } | |
| logger.atFine().log("Rewinding %s", cmd); | |
| if (!validRefOperation(cmd)) { | |
| return; | |
| } | |
| validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd); | |
| if (cmd.getResult() != NOT_ATTEMPTED) { | |
| return; | |
| } | |
| Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE); | |
| if (err.isPresent()) { | |
| rejectProhibited(cmd, err.get()); | |
| } | |
| } | |
| } | |
| private Optional<AuthException> checkRefPermission(ReceiveCommand cmd, RefPermission perm) | |
| throws PermissionBackendException { | |
| return checkRefPermission(permissions.ref(cmd.getRefName()), perm); | |
| } | |
| private Optional<AuthException> checkRefPermission( | |
| PermissionBackend.ForRef forRef, RefPermission perm) throws PermissionBackendException { | |
| try { | |
| forRef.check(perm); | |
| return Optional.empty(); | |
| } catch (AuthException e) { | |
| return Optional.of(e); | |
| } | |
| } | |
| private void rejectProhibited(ReceiveCommand cmd, AuthException err) { | |
| err.getAdvice().ifPresent(a -> errors.put(a, cmd.getRefName())); | |
| reject(cmd, prohibited(err, cmd.getRefName())); | |
| } | |
| private static String prohibited(AuthException e, String alreadyDisplayedResource) { | |
| String msg = e.getMessage(); | |
| if (e instanceof PermissionDeniedException) { | |
| PermissionDeniedException pde = (PermissionDeniedException) e; | |
| if (pde.getResource().isPresent() | |
| && pde.getResource().get().equals(alreadyDisplayedResource)) { | |
| // Avoid repeating resource name if exactly the given name was already displayed by the | |
| // generic git push machinery. | |
| msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission(); | |
| } | |
| } | |
| return "prohibited by Gerrit: " + msg; | |
| } | |
| static class MagicBranchInput { | |
| private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings(); | |
| private final IdentifiedUser user; | |
| private final ProjectState projectState; | |
| private final boolean defaultPublishComments; | |
| final ReceiveCommand cmd; | |
| final LabelTypes labelTypes; | |
| /** | |
| * Draft comments are published with the commit iff {@code --publish-comments} is set. All | |
| * drafts are withheld (overriding the option) if at least one of the following conditions are | |
| * met: | |
| * | |
| * <ul> | |
| * <li>Installed {@link CommentValidator} plugins reject one or more draft comments. | |
| * <li>One or more comments exceed the maximum comment size (see {@link | |
| * CommentSizeValidator}). | |
| * <li>The maximum number of comments would be exceeded (see {@link CommentCountValidator}). | |
| * </ul> | |
| */ | |
| private boolean withholdComments = false; | |
| BranchNameKey dest; | |
| PermissionBackend.ForRef perm; | |
| Set<String> reviewer = Sets.newLinkedHashSet(); | |
| Set<String> cc = Sets.newLinkedHashSet(); | |
| Map<String, Short> labels = new HashMap<>(); | |
| String message; | |
| List<RevCommit> baseCommit; | |
| CmdLineParser cmdLineParser; | |
| Set<String> hashtags = new HashSet<>(); | |
| @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing") | |
| String trace; | |
| @Option( | |
| name = "--deadline", | |
| metaVar = "NAME", | |
| usage = "deadline after which the push should be aborted") | |
| String deadline; | |
| @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes") | |
| List<ObjectId> base; | |
| @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes") | |
| String topic; | |
| @Option(name = "--private", usage = "mark new/updated change as private") | |
| boolean isPrivate; | |
| @Option(name = "--remove-private", usage = "remove privacy flag from updated change") | |
| boolean removePrivate; | |
| /** | |
| * The skip-validation option is defined to allow parsing it using the {@link #cmdLineParser}. | |
| * However we do not allow this option for pushes to magic branches. This option is used to fail | |
| * with a proper error message. | |
| */ | |
| @Option(name = "--skip-validation", usage = "skips commit validation") | |
| boolean skipValidation; | |
| @Option( | |
| name = "--wip", | |
| aliases = {"-work-in-progress"}, | |
| usage = "mark change as work in progress") | |
| boolean workInProgress; | |
| @Option(name = "--ready", usage = "mark change as ready") | |
| boolean ready; | |
| @Option( | |
| name = "--edit", | |
| aliases = {"-e"}, | |
| usage = "upload as change edit") | |
| boolean edit; | |
| @Option(name = "--submit", usage = "immediately submit the change") | |
| boolean submit; | |
| @Option(name = "--merged", usage = "create single change for a merged commit") | |
| boolean merged; | |
| @Option(name = "--publish-comments", usage = "publish all draft comments on updated changes") | |
| private boolean publishComments; | |
| @Option( | |
| name = "--no-publish-comments", | |
| aliases = {"--np"}, | |
| usage = "do not publish draft comments") | |
| private boolean noPublishComments; | |
| @Option( | |
| name = "--notify", | |
| usage = | |
| "Notify handling that defines to whom email notifications " | |
| + "should be sent. Allowed values are NONE, OWNER, " | |
| + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.") | |
| private NotifyHandling notifyHandling; | |
| @Option( | |
| name = "--notify-to", | |
| metaVar = "USER", | |
| usage = "user that should be notified one time by email") | |
| List<Account.Id> notifyTo = new ArrayList<>(); | |
| @Option( | |
| name = "--notify-cc", | |
| metaVar = "USER", | |
| usage = "user that should be CC'd one time by email") | |
| List<Account.Id> notifyCc = new ArrayList<>(); | |
| @Option( | |
| name = "--notify-bcc", | |
| metaVar = "USER", | |
| usage = "user that should be BCC'd one time by email") | |
| List<Account.Id> notifyBcc = new ArrayList<>(); | |
| @Option( | |
| name = "--reviewer", | |
| aliases = {"-r"}, | |
| metaVar = "REVIEWER", | |
| usage = "add reviewer to changes") | |
| void reviewer(String str) { | |
| reviewer.add(str); | |
| } | |
| @Option(name = "--cc", metaVar = "CC", usage = "add CC to changes") | |
| void cc(String str) { | |
| cc.add(str); | |
| } | |
| @Option( | |
| name = "--label", | |
| aliases = {"-l"}, | |
| metaVar = "LABEL+VALUE", | |
| usage = "label(s) to assign (defaults to +1 if no value provided)") | |
| void addLabel(String token) throws CmdLineException { | |
| LabelVote v = LabelVote.parse(token); | |
| try { | |
| LabelType.checkName(v.label()); | |
| ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value()); | |
| } catch (BadRequestException e) { | |
| throw cmdLineParser.reject(e.getMessage()); | |
| } | |
| labels.put(v.label(), v.value()); | |
| } | |
| @Option( | |
| name = "--message", | |
| aliases = {"-m"}, | |
| metaVar = "MESSAGE", | |
| usage = "Comment message to apply to the review") | |
| void addMessage(String token) { | |
| // Many characters have special meaning in the context of a git ref. | |
| // | |
| // Clients can use underscores to represent spaces. | |
| message = token.replace("_", " "); | |
| try { | |
| // Other characters can be represented using percent-encoding. | |
| message = URLDecoder.decode(message, UTF_8.name()); | |
| } catch (IllegalArgumentException e) { | |
| // Ignore decoding errors; leave message as percent-encoded. | |
| } catch (UnsupportedEncodingException e) { | |
| // This shouldn't happen; surely URLDecoder recognizes UTF-8. | |
| throw new IllegalStateException(e); | |
| } | |
| } | |
| @Option( | |
| name = "--hashtag", | |
| aliases = {"-t"}, | |
| metaVar = "HASHTAG", | |
| usage = "add hashtag to changes") | |
| void addHashtag(String token) { | |
| String hashtag = cleanupHashtag(token); | |
| if (!hashtag.isEmpty()) { | |
| hashtags.add(hashtag); | |
| } | |
| } | |
| @UsedAt(UsedAt.Project.GOOGLE) | |
| @SuppressWarnings("unused") // unused in upstream, but used at Google | |
| @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand") | |
| private boolean createCodToken; | |
| @Option( | |
| name = "--ignore-automatic-attention-set-rules", | |
| aliases = {"-ias", "-ignore-attention-set"}, | |
| usage = "do not change the attention set on this push") | |
| boolean ignoreAttentionSet; | |
| MagicBranchInput( | |
| IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) { | |
| this.user = user; | |
| this.projectState = projectState; | |
| this.cmd = cmd; | |
| this.labelTypes = labelTypes; | |
| GeneralPreferencesInfo prefs = user.state().generalPreferences(); | |
| this.defaultPublishComments = | |
| prefs != null | |
| ? firstNonNull(user.state().generalPreferences().publishCommentsOnPush, false) | |
| : false; | |
| } | |
| /** | |
| * Get reviewer strings from magic branch options, combined with additional recipients computed | |
| * from some other place. | |
| * | |
| * <p>The set of reviewers on a change includes strings passed explicitly via options as well as | |
| * account IDs computed from the commit message itself. | |
| * | |
| * @param additionalRecipients recipients parsed from the commit. | |
| * @return set of reviewer strings to pass to {@code ReviewerModifier}. | |
| */ | |
| ImmutableSet<String> getCombinedReviewers(MailRecipients additionalRecipients) { | |
| return getCombinedReviewers(reviewer, additionalRecipients.getReviewers()); | |
| } | |
| /** | |
| * Get CC strings from magic branch options, combined with additional recipients computed from | |
| * some other place. | |
| * | |
| * <p>The set of CCs on a change includes strings passed explicitly via options as well as | |
| * account IDs computed from the commit message itself. | |
| * | |
| * @param additionalRecipients recipients parsed from the commit. | |
| * @return set of CC strings to pass to {@code ReviewerModifier}. | |
| */ | |
| ImmutableSet<String> getCombinedCcs(MailRecipients additionalRecipients) { | |
| return getCombinedReviewers(cc, additionalRecipients.getCcOnly()); | |
| } | |
| private static ImmutableSet<String> getCombinedReviewers( | |
| Set<String> strings, Set<Account.Id> ids) { | |
| return Streams.concat(strings.stream(), ids.stream().map(Account.Id::toString)) | |
| .collect(toImmutableSet()); | |
| } | |
| void setWithholdComments(boolean withholdComments) { | |
| this.withholdComments = withholdComments; | |
| } | |
| boolean shouldPublishComments() { | |
| if (withholdComments) { | |
| // Validation messages of type WARNING have already been added, now withhold the comments. | |
| return false; | |
| } | |
| if (publishComments) { | |
| return true; | |
| } | |
| if (noPublishComments) { | |
| return false; | |
| } | |
| return defaultPublishComments; | |
| } | |
| /** | |
| * returns the destination ref of the magic branch, and populates options in the cmdLineParser. | |
| */ | |
| String parse(ListMultimap<String, String> pushOptions) throws CmdLineException { | |
| String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName())); | |
| ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions); | |
| // Process and lop off the "%OPTION" suffix. | |
| int optionStart = ref.indexOf('%'); | |
| if (0 < optionStart) { | |
| for (String s : COMMAS.split(ref.substring(optionStart + 1))) { | |
| int e = s.indexOf('='); | |
| if (0 < e) { | |
| options.put(s.substring(0, e), s.substring(e + 1)); | |
| } else { | |
| options.put(s, ""); | |
| } | |
| } | |
| ref = ref.substring(0, optionStart); | |
| } | |
| if (!options.isEmpty()) { | |
| cmdLineParser.parseOptionMap(options); | |
| } | |
| return ref; | |
| } | |
| public boolean shouldSetWorkInProgressOnNewChanges() { | |
| // When wip or ready explicitly provided, leave it as is. | |
| if (workInProgress) { | |
| return true; | |
| } | |
| if (ready) { | |
| return false; | |
| } | |
| return projectState.is(BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT) | |
| || firstNonNull(user.state().generalPreferences().workInProgressByDefault, false); | |
| } | |
| NotifyResolver.Result getNotifyForNewChange() { | |
| return NotifyResolver.Result.create( | |
| firstNonNull( | |
| notifyHandling, | |
| shouldSetWorkInProgressOnNewChanges() ? NotifyHandling.OWNER : NotifyHandling.ALL), | |
| ImmutableSetMultimap.<RecipientType, Account.Id>builder() | |
| .putAll(RecipientType.TO, notifyTo) | |
| .putAll(RecipientType.CC, notifyCc) | |
| .putAll(RecipientType.BCC, notifyBcc) | |
| .build()); | |
| } | |
| NotifyHandling getNotifyHandling(ChangeNotes notes) { | |
| requireNonNull(notes); | |
| if (notifyHandling != null) { | |
| return notifyHandling; | |
| } | |
| if (workInProgress || (!ready && notes.getChange().isWorkInProgress())) { | |
| return NotifyHandling.OWNER; | |
| } | |
| return NotifyHandling.ALL; | |
| } | |
| } | |
| /** | |
| * Parse the magic branch data (refs/for/BRANCH/OPTIONALTOPIC%OPTIONS) into the magicBranch | |
| * member. | |
| * | |
| * <p>Assumes we are handling a magic branch here. | |
| */ | |
| private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException, IOException { | |
| try (TraceTimer traceTimer = newTimer("parseMagicBranch")) { | |
| logger.atFine().log("Found magic branch %s", cmd.getRefName()); | |
| MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes); | |
| String ref; | |
| magicBranch.cmdLineParser = optionParserFactory.create(magicBranch); | |
| // Filter out plugin push options, as the parser would reject them as unknown. | |
| ImmutableListMultimap<String, String> pushOptionsToParse = | |
| pushOptions.entries().stream() | |
| .filter(e -> !isPluginPushOption(e.getKey())) | |
| .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue())); | |
| try { | |
| ref = magicBranch.parse(pushOptionsToParse); | |
| } catch (CmdLineException e) { | |
| if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) { | |
| logger.atFine().log("Invalid branch syntax"); | |
| reject(cmd, e.getMessage()); | |
| return; | |
| } | |
| ref = null; // never happens | |
| } | |
| if (magicBranch.skipValidation) { | |
| reject( | |
| cmd, | |
| String.format( | |
| "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION)); | |
| return; | |
| } | |
| if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) { | |
| reject( | |
| cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH)); | |
| } | |
| if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) { | |
| StringWriter w = new StringWriter(); | |
| w.write("\nHelp for refs/for/branch:\n\n"); | |
| magicBranch.cmdLineParser.printUsage(w, null); | |
| String pluginPushOptionsHelp = | |
| StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false) | |
| .map( | |
| e -> | |
| String.format( | |
| "-o %s~%s: %s", | |
| e.getPluginName(), e.get().getName(), e.get().getDescription())) | |
| .sorted() | |
| .collect(joining("\n")); | |
| if (!pluginPushOptionsHelp.isEmpty()) { | |
| w.write("\nPlugin push options:\n" + pluginPushOptionsHelp); | |
| } | |
| addMessage(w.toString()); | |
| reject(cmd, "see help"); | |
| return; | |
| } | |
| if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) { | |
| logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF); | |
| ref = RefNames.refsUsers(user.getAccountId()); | |
| } | |
| // Pushing changes for review usually requires that the target branch exists, but there is an | |
| // exception for the branch to which HEAD points to and for refs/meta/config. Pushing for | |
| // review to these branches is allowed even if the branch does not exist yet. This allows to | |
| // push initial code for review to an empty repository and to review an initial project | |
| // configuration. | |
| if (receivePackRefCache.exactRef(ref) == null | |
| && !ref.equals(readHEAD(repo)) | |
| && !ref.equals(RefNames.REFS_CONFIG)) { | |
| logger.atFine().log("Ref %s not found", ref); | |
| if (ref.startsWith(Constants.R_HEADS)) { | |
| String n = ref.substring(Constants.R_HEADS.length()); | |
| reject(cmd, "branch " + n + " not found"); | |
| } else { | |
| reject(cmd, ref + " not found"); | |
| } | |
| return; | |
| } | |
| magicBranch.dest = BranchNameKey.create(project.getNameKey(), ref); | |
| magicBranch.perm = permissions.ref(ref); | |
| Optional<AuthException> err = | |
| checkRefPermission(magicBranch.perm, RefPermission.READ) | |
| .map(Optional::of) | |
| .orElse(checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE)); | |
| if (err.isPresent()) { | |
| rejectProhibited(cmd, err.get()); | |
| return; | |
| } | |
| if (magicBranch.isPrivate && magicBranch.removePrivate) { | |
| reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive"); | |
| return; | |
| } | |
| boolean privateByDefault = | |
| projectCache | |
| .get(project.getNameKey()) | |
| .orElseThrow(illegalState(project.getNameKey())) | |
| .is(BooleanProjectConfig.PRIVATE_BY_DEFAULT); | |
| setChangeAsPrivate = | |
| magicBranch.isPrivate || (privateByDefault && !magicBranch.removePrivate); | |
| if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) { | |
| reject(cmd, "private changes are disabled"); | |
| return; | |
| } | |
| if (magicBranch.workInProgress && magicBranch.ready) { | |
| reject(cmd, "the options 'wip' and 'ready' are mutually exclusive"); | |
| return; | |
| } | |
| if (magicBranch.publishComments && magicBranch.noPublishComments) { | |
| reject( | |
| cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive"); | |
| return; | |
| } | |
| if (magicBranch.submit) { | |
| err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT); | |
| if (err.isPresent()) { | |
| rejectProhibited(cmd, err.get()); | |
| return; | |
| } | |
| } | |
| RevWalk walk = receivePack.getRevWalk(); | |
| RevCommit tip; | |
| try { | |
| tip = walk.parseCommit(magicBranch.cmd.getNewId()); | |
| logger.atFine().log("Tip of push: %s", tip.name()); | |
| } catch (IOException ex) { | |
| magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT); | |
| logger.atSevere().withCause(ex).log( | |
| "Invalid pack upload; one or more objects weren't sent"); | |
| return; | |
| } | |
| String destBranch = magicBranch.dest.branch(); | |
| try { | |
| if (magicBranch.merged) { | |
| if (magicBranch.base != null) { | |
| reject(cmd, "cannot use merged with base"); | |
| return; | |
| } | |
| Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch()); | |
| if (refTip == null) { | |
| reject(cmd, magicBranch.dest.branch() + " not found"); | |
| return; | |
| } | |
| RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId()); | |
| if (!walk.isMergedInto(tip, branchTip)) { | |
| reject(cmd, "not merged into branch"); | |
| return; | |
| } | |
| } | |
| // If tip is a merge commit, or the root commit or | |
| // if %base or %merged was specified, ignore newChangeForAllNotInTarget. | |
| if (tip.getParentCount() > 1 | |
| || magicBranch.base != null | |
| || magicBranch.merged | |
| || tip.getParentCount() == 0) { | |
| logger.atFine().log("Forcing newChangeForAllNotInTarget = false"); | |
| newChangeForAllNotInTarget = false; | |
| } | |
| if (magicBranch.base != null) { | |
| logger.atFine().log("Handling %%base: %s", magicBranch.base); | |
| magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size()); | |
| for (ObjectId id : magicBranch.base) { | |
| try { | |
| magicBranch.baseCommit.add(walk.parseCommit(id)); | |
| } catch (IncorrectObjectTypeException notCommit) { | |
| reject(cmd, "base must be a commit"); | |
| return; | |
| } catch (MissingObjectException e) { | |
| reject(cmd, "base not found"); | |
| return; | |
| } catch (IOException e) { | |
| throw new StorageException( | |
| String.format("Project %s cannot read %s", project.getName(), id.name()), e); | |
| } | |
| } | |
| } else if (newChangeForAllNotInTarget) { | |
| Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch()); | |
| if (refTip != null) { | |
| RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId()); | |
| magicBranch.baseCommit = Collections.singletonList(branchTip); | |
| logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name()); | |
| } else { | |
| // The target branch does not exist. Usually pushing changes for review requires that | |
| // the | |
| // target branch exists, but there is an exception for the branch to which HEAD points | |
| // to | |
| // and for refs/meta/config. Pushing for review to these branches is allowed even if the | |
| // branch does not exist yet. This allows to push initial code for review to an empty | |
| // repository and to review an initial project configuration. | |
| if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) { | |
| reject(cmd, magicBranch.dest.branch() + " not found"); | |
| return; | |
| } | |
| } | |
| } | |
| } catch (IOException e) { | |
| throw new StorageException( | |
| String.format("Error walking to %s in project %s", destBranch, project.getName()), e); | |
| } | |
| if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) { | |
| this.magicBranch = magicBranch; | |
| this.result.magicPush(true); | |
| } | |
| } | |
| } | |
| private boolean isPluginPushOption(String pushOptionName) { | |
| if (transitionalPluginOptions.contains(pushOptionName)) { | |
| return true; | |
| } | |
| return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false) | |
| .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName())); | |
| } | |
| // Validate that the new commits are connected with the target | |
| // branch. If they aren't, we want to abort. We do this check by | |
| // looking to see if we can compute a merge base between the new | |
| // commits and the target branch head. | |
| private boolean validateConnected(ReceiveCommand cmd, BranchNameKey dest, RevCommit tip) { | |
| try (TraceTimer traceTimer = | |
| newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) { | |
| RevWalk walk = receivePack.getRevWalk(); | |
| try { | |
| Ref targetRef = receivePackRefCache.exactRef(dest.branch()); | |
| if (targetRef == null || targetRef.getObjectId() == null) { | |
| // The destination branch does not yet exist. Assume the | |
| // history being sent for review will start it and thus | |
| // is "connected" to the branch. | |
| logger.atFine().log("Branch is unborn"); | |
| // This is not an error condition. | |
| return true; | |
| } | |
| RevCommit h = walk.parseCommit(targetRef.getObjectId()); | |
| logger.atFine().log("Current branch tip: %s", h.name()); | |
| RevFilter oldRevFilter = walk.getRevFilter(); | |
| try { | |
| walk.reset(); | |
| walk.setRevFilter(RevFilter.MERGE_BASE); | |
| walk.markStart(tip); | |
| walk.markStart(h); | |
| if (walk.next() == null) { | |
| reject(cmd, "no common ancestry"); | |
| return false; | |
| } | |
| } finally { | |
| walk.reset(); | |
| walk.setRevFilter(oldRevFilter); | |
| } | |
| } catch (IOException e) { | |
| cmd.setResult(REJECTED_MISSING_OBJECT); | |
| logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent"); | |
| return false; | |
| } | |
| return true; | |
| } | |
| } | |
| private static String readHEAD(Repository repo) { | |
| try { | |
| String head = repo.getFullBranch(); | |
| logger.atFine().log("HEAD = %s", head); | |
| return head; | |
| } catch (IOException e) { | |
| throw new StorageException("Cannot read HEAD symref", e); | |
| } | |
| } | |
| /** | |
| * Update an existing change. If draft comments are to be published, these are validated and may | |
| * be withheld. | |
| * | |
| * @return True if the command succeeded, false if it was rejected. | |
| */ | |
| private boolean requestReplaceAndValidateComments( | |
| ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) | |
| throws IOException { | |
| try (TraceTimer traceTimer = newTimer("requestReplaceAndValidateComments")) { | |
| if (change.isClosed()) { | |
| reject( | |
| cmd, | |
| changeFormatter.changeClosed( | |
| ChangeReportFormatter.Input.builder().setChange(change).build())); | |
| return false; | |
| } | |
| ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto); | |
| if (replaceByChange.containsKey(req.ontoChange)) { | |
| reject(cmd, "duplicate request"); | |
| return false; | |
| } | |
| if (magicBranch != null && magicBranch.shouldPublishComments()) { | |
| List<HumanComment> drafts = | |
| draftCommentsReader.getDraftsByChangeAndDraftAuthor( | |
| notesFactory.createChecked(change), user.getAccountId()); | |
| ImmutableList<CommentForValidation> draftsForValidation = | |
| drafts.stream() | |
| .map( | |
| comment -> | |
| CommentForValidation.create( | |
| CommentSource.HUMAN, | |
| comment.lineNbr > 0 | |
| ? CommentType.INLINE_COMMENT | |
| : CommentType.FILE_COMMENT, | |
| comment.message, | |
| comment.message.length())) | |
| .collect(toImmutableList()); | |
| CommentValidationContext ctx = | |
| CommentValidationContext.create( | |
| change.getChangeId(), change.getProject().get(), change.getDest().branch()); | |
| ImmutableList<CommentValidationFailure> commentValidationFailures = | |
| PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation); | |
| magicBranch.setWithholdComments(!commentValidationFailures.isEmpty()); | |
| commentValidationFailures.forEach( | |
| failure -> | |
| addMessage( | |
| "Comment validation failure: " + failure.getMessage(), | |
| ValidationMessage.Type.WARNING)); | |
| } | |
| replaceByChange.put(req.ontoChange, req); | |
| return true; | |
| } | |
| } | |
| private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) { | |
| for (CreateRequest create : newChanges) { | |
| try { | |
| receivePack.getRevWalk().parseBody(create.commit); | |
| } catch (IOException e) { | |
| throw new StorageException("Can't parse commit", e); | |
| } | |
| List<String> idList = changeUtil.getChangeIdsFromFooter(create.commit); | |
| if (idList.isEmpty()) { | |
| messages.add( | |
| new ValidationMessage("warning: pushing without Change-Id is deprecated", false)); | |
| break; | |
| } | |
| } | |
| } | |
| private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) | |
| throws IOException { | |
| try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) { | |
| logger.atFine().log("Finding new and replaced changes"); | |
| List<CreateRequest> newChanges = new ArrayList<>(); | |
| GroupCollector groupCollector = | |
| GroupCollector.create(receivePackRefCache, psUtil, notesFactory, project.getNameKey()); | |
| BranchCommitValidator validator = | |
| commitValidatorFactory.create(projectState, magicBranch.dest, user); | |
| try { | |
| RevCommit start = setUpWalkForSelectingChanges(); | |
| if (start == null) { | |
| return ImmutableList.of(); | |
| } | |
| LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>(); | |
| Set<Change.Key> newChangeIds = new HashSet<>(); | |
| int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user); | |
| int total = 0; | |
| int alreadyTracked = 0; | |
| boolean rejectImplicitMerges = | |
| start.getParentCount() == 1 | |
| && projectCache | |
| .get(project.getNameKey()) | |
| .orElseThrow(illegalState(project.getNameKey())) | |
| .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES) | |
| // Don't worry about implicit merges when creating changes for | |
| // already-merged commits; they're already in history, so it's too | |
| // late. | |
| && !magicBranch.merged; | |
| Set<RevCommit> mergedParents; | |
| if (rejectImplicitMerges) { | |
| mergedParents = new HashSet<>(); | |
| } else { | |
| mergedParents = null; | |
| } | |
| for (; ; ) { | |
| RevCommit c = receivePack.getRevWalk().next(); | |
| if (c == null) { | |
| break; | |
| } | |
| total++; | |
| receivePack.getRevWalk().parseBody(c); | |
| String name = c.name(); | |
| groupCollector.visit(c); | |
| Collection<PatchSet.Id> existingPatchSets = | |
| receivePackRefCache.patchSetIdsFromObjectId(c); | |
| if (rejectImplicitMerges) { | |
| Collections.addAll(mergedParents, c.getParents()); | |
| mergedParents.remove(c); | |
| } | |
| boolean commitAlreadyTracked = !existingPatchSets.isEmpty(); | |
| if (commitAlreadyTracked) { | |
| alreadyTracked++; | |
| if (!(newChangeForAllNotInTarget || magicBranch.base != null)) { | |
| continue; | |
| } | |
| } | |
| List<String> idList = changeUtil.getChangeIdsFromFooter(c); | |
| if (!idList.isEmpty()) { | |
| pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim()))); | |
| } else { | |
| pending.put(c, lookupByCommit(c)); | |
| } | |
| int n = pending.size() + newChanges.size(); | |
| if (maxBatchChanges != 0 && n > maxBatchChanges) { | |
| logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges); | |
| reject( | |
| magicBranch.cmd, | |
| "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges); | |
| return ImmutableList.of(); | |
| } | |
| if (commitAlreadyTracked) { | |
| boolean changeExistsOnDestBranch = false; | |
| for (ChangeData cd : pending.get(c).destChanges) { | |
| if (cd.change().getDest().equals(magicBranch.dest)) { | |
| changeExistsOnDestBranch = true; | |
| break; | |
| } | |
| } | |
| if (changeExistsOnDestBranch) { | |
| continue; | |
| } | |
| logger.atFine().log( | |
| "Creating new change for %s even though it is already tracked", name); | |
| } | |
| BranchCommitValidator.Result validationResult = | |
| validator.validateCommit( | |
| repo, | |
| receivePack.getRevWalk().getObjectReader(), | |
| magicBranch.cmd, | |
| c, | |
| ImmutableListMultimap.copyOf(pushOptions), | |
| magicBranch.merged, | |
| rejectCommits, | |
| null); | |
| messages.addAll(validationResult.messages()); | |
| if (!validationResult.isValid()) { | |
| // Not a change the user can propose? Abort as early as possible. | |
| logger.atFine().log("Aborting early due to invalid commit"); | |
| return ImmutableList.of(); | |
| } | |
| // Don't allow merges to be uploaded in commit chain via all-not-in-target | |
| if (newChangeForAllNotInTarget && c.getParentCount() > 1) { | |
| reject( | |
| magicBranch.cmd, | |
| "Pushing merges in commit chains with 'all not in target' is not allowed,\n" | |
| + "to override please set the base manually"); | |
| logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name); | |
| // TODO(dborowitz): Should we early return here? | |
| } | |
| if (idList.isEmpty()) { | |
| newChanges.add(new CreateRequest(c, magicBranch.dest.branch(), newProgress)); | |
| continue; | |
| } | |
| } | |
| logger.atFine().log( | |
| "Finished initial RevWalk with %d commits total: %d already" | |
| + " tracked, %d new changes with no Change-Id, and %d deferred" | |
| + " lookups", | |
| total, alreadyTracked, newChanges.size(), pending.size()); | |
| if (rejectImplicitMerges) { | |
| rejectImplicitMerges(mergedParents); | |
| } | |
| for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) { | |
| ChangeLookup p = itr.next(); | |
| if (p.changeKey == null) { | |
| continue; | |
| } | |
| if (newChangeIds.contains(p.changeKey)) { | |
| logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey); | |
| reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES); | |
| return ImmutableList.of(); | |
| } | |
| List<ChangeData> changes = p.destChanges; | |
| if (changes.size() > 1) { | |
| logger.atFine().log( | |
| "Multiple changes in branch %s with Change-Id %s: %s", | |
| magicBranch.dest, | |
| p.changeKey, | |
| changes.stream().map(cd -> cd.getId().toString()).collect(joining())); | |
| // WTF, multiple changes in this branch have the same key? | |
| // Since the commit is new, the user should recreate it with | |
| // a different Change-Id. In practice, we should never see | |
| // this error message as Change-Id should be unique per branch. | |
| // | |
| reject(magicBranch.cmd, p.changeKey.get() + " has duplicates"); | |
| return ImmutableList.of(); | |
| } | |
| if (changes.size() == 1) { | |
| // Schedule as a replacement to this one matching change. | |
| // | |
| ObjectId currentPs = changes.get(0).currentPatchSet().commitId(); | |
| // If Commit is already current PatchSet of target Change. | |
| if (p.commit.equals(currentPs)) { | |
| if (pending.size() == 1) { | |
| // There are no commits left to check, all commits in pending were already | |
| // current PatchSet of the corresponding target changes. | |
| reject(magicBranch.cmd, "commit(s) already exists (as current patchset)"); | |
| } else { | |
| // Commit is already current PatchSet. | |
| // Remove from pending and try next commit. | |
| itr.remove(); | |
| continue; | |
| } | |
| } | |
| if (requestReplaceAndValidateComments( | |
| magicBranch.cmd, false, changes.get(0).change(), p.commit)) { | |
| continue; | |
| } | |
| return ImmutableList.of(); | |
| } | |
| if (changes.isEmpty()) { | |
| if (!isValidChangeId(p.changeKey.get())) { | |
| reject(magicBranch.cmd, "invalid Change-Id"); | |
| return ImmutableList.of(); | |
| } | |
| // In case the change look up from the index failed, | |
| // double check against the existing refs | |
| if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) { | |
| if (pending.size() == 1) { | |
| reject(magicBranch.cmd, "commit(s) already exists (as current patchset)"); | |
| return ImmutableList.of(); | |
| } | |
| itr.remove(); | |
| continue; | |
| } | |
| newChangeIds.add(p.changeKey); | |
| } | |
| newChanges.add(new CreateRequest(p.commit, magicBranch.dest.branch(), newProgress)); | |
| } | |
| logger.atFine().log( | |
| "Finished deferred lookups with %d updates and %d new changes", | |
| replaceByChange.size(), newChanges.size()); | |
| } catch (IOException e) { | |
| // Should never happen, the core receive process would have | |
| // identified the missing object earlier before we got control. | |
| throw new StorageException("Invalid pack upload; one or more objects weren't sent", e); | |
| } | |
| if (newChanges.isEmpty() && replaceByChange.isEmpty()) { | |
| reject(magicBranch.cmd, "no new changes"); | |
| return ImmutableList.of(); | |
| } | |
| if (!newChanges.isEmpty() && magicBranch.edit) { | |
| reject(magicBranch.cmd, "edit is not supported for new changes"); | |
| return ImmutableList.copyOf(newChanges); | |
| } | |
| SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups(); | |
| List<Integer> newIds = seq.nextChangeIds(newChanges.size()); | |
| for (int i = 0; i < newChanges.size(); i++) { | |
| CreateRequest create = newChanges.get(i); | |
| create.setChangeId(newIds.get(i)); | |
| create.groups = ImmutableList.copyOf(groups.get(create.commit)); | |
| } | |
| for (ReplaceRequest replace : replaceByChange.values()) { | |
| replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId)); | |
| } | |
| logger.atFine().log("Finished updating groups from GroupCollector"); | |
| return ImmutableList.copyOf(newChanges); | |
| } | |
| } | |
| private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) { | |
| try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) { | |
| for (PatchSet.Id psId : existingPatchSets) { | |
| ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId()); | |
| Change change = notes.getChange(); | |
| if (change.getDest().equals(magicBranch.dest)) { | |
| logger.atFine().log("Found change %s from existing refs.", change.getKey()); | |
| // Reindex the change asynchronously, ignoring errors. | |
| @SuppressWarnings("unused") | |
| Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId()); | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| } | |
| private RevCommit setUpWalkForSelectingChanges() throws IOException { | |
| try (TraceTimer traceTimer = newTimer("setUpWalkForSelectingChanges")) { | |
| RevWalk rw = receivePack.getRevWalk(); | |
| RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId()); | |
| rw.reset(); | |
| rw.sort(RevSort.TOPO); | |
| rw.sort(RevSort.REVERSE, true); | |
| receivePack.getRevWalk().markStart(start); | |
| if (magicBranch.baseCommit != null) { | |
| markExplicitBasesUninteresting(); | |
| } else if (magicBranch.merged) { | |
| logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name()); | |
| for (RevCommit c : start.getParents()) { | |
| rw.markUninteresting(c); | |
| } | |
| } else { | |
| markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.branch() : null); | |
| } | |
| return start; | |
| } | |
| } | |
| private void markExplicitBasesUninteresting() throws IOException { | |
| try (TraceTimer traceTimer = newTimer("markExplicitBasesUninteresting")) { | |
| logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size()); | |
| for (RevCommit c : magicBranch.baseCommit) { | |
| receivePack.getRevWalk().markUninteresting(c); | |
| } | |
| Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch()); | |
| if (targetRef != null) { | |
| logger.atFine().log( | |
| "Marking target ref %s (%s) uninteresting", | |
| magicBranch.dest.branch(), targetRef.getObjectId().name()); | |
| receivePack | |
| .getRevWalk() | |
| .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId())); | |
| } | |
| } | |
| } | |
| private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException { | |
| try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) { | |
| if (!mergedParents.isEmpty()) { | |
| Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch()); | |
| if (targetRef != null) { | |
| RevWalk rw = receivePack.getRevWalk(); | |
| RevCommit tip = rw.parseCommit(targetRef.getObjectId()); | |
| boolean containsImplicitMerges = true; | |
| for (RevCommit p : mergedParents) { | |
| containsImplicitMerges &= !rw.isMergedInto(p, tip); | |
| } | |
| if (containsImplicitMerges) { | |
| rw.reset(); | |
| for (RevCommit p : mergedParents) { | |
| rw.markStart(p); | |
| } | |
| rw.markUninteresting(tip); | |
| RevCommit c; | |
| while ((c = rw.next()) != null) { | |
| rw.parseBody(c); | |
| messages.add( | |
| new CommitValidationMessage( | |
| "Implicit Merge of " | |
| + abbreviateName(c, rw.getObjectReader()) | |
| + " " | |
| + c.getShortMessage(), | |
| ValidationMessage.Type.ERROR)); | |
| } | |
| reject(magicBranch.cmd, "implicit merges detected"); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Mark all branch tips as uninteresting in the given revwalk, | |
| // so we get only the new commits when walking rw. | |
| private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) throws IOException { | |
| try (TraceTimer traceTimer = | |
| newTimer("markHeadsAsUninteresting", Metadata.builder().branchName(forRef))) { | |
| int i = 0; | |
| for (Ref ref : | |
| Iterables.concat( | |
| receivePackRefCache.byPrefix(R_HEADS), | |
| Collections.singletonList(receivePackRefCache.exactRef(forRef)))) { | |
| if (ref != null && ref.getObjectId() != null) { | |
| try { | |
| rw.markUninteresting(rw.parseCommit(ref.getObjectId())); | |
| i++; | |
| } catch (IOException e) { | |
| logger.atWarning().withCause(e).log( | |
| "Invalid ref %s in %s", ref.getName(), project.getName()); | |
| } | |
| } | |
| } | |
| logger.atFine().log("Marked %d heads as uninteresting", i); | |
| } | |
| } | |
| private static boolean isValidChangeId(String idStr) { | |
| return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$"); | |
| } | |
| private static class ChangeLookup { | |
| final RevCommit commit; | |
| @Nullable final Change.Key changeKey; | |
| final List<ChangeData> destChanges; | |
| ChangeLookup(RevCommit c, @Nullable Change.Key key, final List<ChangeData> destChanges) { | |
| this.commit = c; | |
| this.changeKey = key; | |
| this.destChanges = destChanges; | |
| } | |
| } | |
| private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) { | |
| try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) { | |
| List<ChangeData> byBranchKeyExactMatch = | |
| queryProvider.get().byBranchKey(magicBranch.dest, key).stream() | |
| .filter(cd -> cd.change().getKey().equals(key)) | |
| .collect(toList()); | |
| return new ChangeLookup(c, key, byBranchKeyExactMatch); | |
| } | |
| } | |
| private ChangeLookup lookupByCommit(RevCommit c) { | |
| try (TraceTimer traceTimer = newTimer("lookupByCommit")) { | |
| return new ChangeLookup( | |
| c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName())); | |
| } | |
| } | |
| /** Represents a commit for which a Change should be created. */ | |
| private class CreateRequest { | |
| final RevCommit commit; | |
| final Task progress; | |
| final String refName; | |
| Change.Id changeId; | |
| ReceiveCommand cmd; | |
| ChangeInserter ins; | |
| List<String> groups = ImmutableList.of(); | |
| Change change; | |
| CreateRequest(RevCommit commit, String refName, Task progress) { | |
| this.commit = commit; | |
| this.refName = refName; | |
| this.progress = progress; | |
| } | |
| private void setChangeId(int id) { | |
| try (TraceTimer traceTimer = newTimer(CreateRequest.class, "setChangeId")) { | |
| changeId = Change.id(id); | |
| ins = | |
| changeInserterFactory | |
| .create(changeId, commit, refName) | |
| .setTopic(magicBranch.topic) | |
| .setPrivate(setChangeAsPrivate) | |
| .setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges()) | |
| // Changes already validated in validateNewCommits. | |
| .setValidate(false); | |
| if (magicBranch.merged) { | |
| ins.setStatus(Change.Status.MERGED); | |
| } | |
| cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName()); | |
| if (receivePack.getPushCertificate() != null) { | |
| ins.setPushCertificate(receivePack.getPushCertificate().toTextWithSignature()); | |
| } | |
| } | |
| } | |
| private void addOps(BatchUpdate bu) throws RestApiException { | |
| try (TraceTimer traceTimer = newTimer(CreateRequest.class, "addOps")) { | |
| checkState(changeId != null, "must call setChangeId before addOps"); | |
| try { | |
| RevWalk rw = receivePack.getRevWalk(); | |
| rw.parseBody(commit); | |
| final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId(); | |
| Account.Id me = user.getAccountId(); | |
| List<FooterLine> footerLines = commit.getFooterLines(); | |
| requireNonNull(magicBranch); | |
| // TODO(dborowitz): Support reviewers by email from footers? Maybe not: kernel developers | |
| // with AOSP accounts already complain about these notifications, and that would make it | |
| // worse. Might be better to get rid of the feature entirely: | |
| // https://groups.google.com/d/topic/repo-discuss/tIFxY7L4DXk/discussion | |
| MailRecipients fromFooters = getRecipientsFromFooters(accountResolver, footerLines); | |
| fromFooters.remove(me); | |
| Map<String, Short> approvals = magicBranch.labels; | |
| StringBuilder msg = | |
| new StringBuilder( | |
| ApprovalsUtil.renderMessageWithApprovals( | |
| psId.get(), approvals, Collections.emptyMap())); | |
| msg.append('.'); | |
| if (!Strings.isNullOrEmpty(magicBranch.message)) { | |
| msg.append("\n").append(magicBranch.message); | |
| } | |
| bu.setNotify(magicBranch.getNotifyForNewChange()); | |
| bu.insertChange( | |
| ins.setReviewersAndCcsAsStrings( | |
| magicBranch.getCombinedReviewers(fromFooters), | |
| magicBranch.getCombinedCcs(fromFooters)) | |
| .setApprovals(approvals) | |
| .setMessage(msg.toString()) | |
| .setRequestScopePropagator(requestScopePropagator) | |
| .setSendMail(true) | |
| .setPatchSetDescription(magicBranch.message)); | |
| if (!magicBranch.hashtags.isEmpty()) { | |
| // Any change owner is allowed to add hashtags when creating a change. | |
| bu.addOp( | |
| changeId, | |
| hashtagsFactory | |
| .create(new HashtagsInput(magicBranch.hashtags)) | |
| .setFireEvent(false)); | |
| } | |
| if (!Strings.isNullOrEmpty(magicBranch.topic)) { | |
| bu.addOp(changeId, setTopicFactory.create(magicBranch.topic)); | |
| } | |
| if (magicBranch.ignoreAttentionSet) { | |
| bu.addOp(changeId, new AttentionSetUnchangedOp()); | |
| } | |
| bu.addOp( | |
| changeId, | |
| new BatchUpdateOp() { | |
| @Override | |
| public boolean updateChange(ChangeContext ctx) { | |
| CreateRequest.this.change = ctx.getChange(); | |
| return false; | |
| } | |
| }); | |
| bu.addOp(changeId, new ChangeProgressOp(progress)); | |
| } catch (Exception e) { | |
| throw asRestApiException(e); | |
| } | |
| } | |
| } | |
| } | |
| private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace) | |
| throws RestApiException, UpdateException, IOException, ConfigInvalidException, | |
| PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("submit")) { | |
| Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size()); | |
| for (CreateRequest r : create) { | |
| requireNonNull( | |
| r.change, | |
| () -> String.format("cannot submit new change %s; op may not have run", r.changeId)); | |
| bySha.put(r.commit, r.change); | |
| } | |
| for (ReplaceRequest r : replace) { | |
| bySha.put(r.newCommitId, r.notes.getChange()); | |
| } | |
| Change tipChange = bySha.get(magicBranch.cmd.getNewId()); | |
| requireNonNull( | |
| tipChange, | |
| () -> | |
| String.format( | |
| "tip of push does not correspond to a change; found these changes: %s", bySha)); | |
| logger.atFine().log( | |
| "Processing submit with tip change %s (%s)", | |
| tipChange.getId(), magicBranch.cmd.getNewId()); | |
| try (MergeOp op = mergeOpProvider.get()) { | |
| SubmitInput submitInput = new SubmitInput(); | |
| submitInput.notify = magicBranch.notifyHandling; | |
| submitInput.notifyDetails = new HashMap<>(); | |
| submitInput.notifyDetails.put( | |
| RecipientType.TO, | |
| new NotifyInfo(magicBranch.notifyTo.stream().map(Object::toString).collect(toList()))); | |
| submitInput.notifyDetails.put( | |
| RecipientType.CC, | |
| new NotifyInfo(magicBranch.notifyCc.stream().map(Object::toString).collect(toList()))); | |
| submitInput.notifyDetails.put( | |
| RecipientType.BCC, | |
| new NotifyInfo(magicBranch.notifyBcc.stream().map(Object::toString).collect(toList()))); | |
| op.merge(tipChange, user, false, submitInput, false); | |
| } | |
| } | |
| } | |
| private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) { | |
| try (TraceTimer traceTimer = | |
| newTimer( | |
| "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) { | |
| try { | |
| readChangesForReplace(); | |
| for (ReplaceRequest req : replaceByChange.values()) { | |
| if (req.inputCommand.getResult() == NOT_ATTEMPTED) { | |
| req.validateNewPatchSet(); | |
| } | |
| } | |
| } catch (IOException | PermissionBackendException e) { | |
| throw new StorageException( | |
| "Cannot read repository before replacement for project " + project.getName(), e); | |
| } | |
| logger.atFine().log("Read %d changes to replace", replaceByChange.size()); | |
| if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) { | |
| // Cancel creations tied to refs/for/ command. | |
| for (ReplaceRequest req : replaceByChange.values()) { | |
| if (req.inputCommand == magicBranch.cmd && req.cmd != null) { | |
| req.cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, "aborted"); | |
| } | |
| } | |
| for (CreateRequest req : newChanges) { | |
| req.cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, "aborted"); | |
| } | |
| } | |
| } | |
| } | |
| private void readChangesForReplace() { | |
| try (TraceTimer traceTimer = newTimer("readChangesForReplace")) { | |
| replaceByChange.values().stream() | |
| .map(r -> r.ontoChange) | |
| .map(id -> notesFactory.create(repo, project.getNameKey(), id)) | |
| .forEach(notes -> replaceByChange.get(notes.getChangeId()).notes = notes); | |
| } | |
| } | |
| /** Represents a commit that should be stored in a new patchset of an existing change. */ | |
| private class ReplaceRequest { | |
| final Change.Id ontoChange; | |
| final ObjectId newCommitId; | |
| final ReceiveCommand inputCommand; | |
| final boolean checkMergedInto; | |
| RevCommit revCommit; | |
| ChangeNotes notes; | |
| BiMap<RevCommit, PatchSet.Id> revisions; | |
| PatchSet.Id psId; | |
| ReceiveCommand prev; | |
| ReceiveCommand cmd; | |
| PatchSetInfo info; | |
| PatchSet.Id priorPatchSet; | |
| List<String> groups = ImmutableList.of(); | |
| ReplaceOp replaceOp; | |
| ReplaceRequest( | |
| Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) | |
| throws IOException { | |
| this.ontoChange = toChange; | |
| this.newCommitId = newCommit.copy(); | |
| this.inputCommand = requireNonNull(cmd); | |
| this.checkMergedInto = checkMergedInto; | |
| try { | |
| revCommit = receivePack.getRevWalk().parseCommit(newCommitId); | |
| } catch (IOException e) { | |
| revCommit = null; | |
| } | |
| revisions = HashBiMap.create(); | |
| for (Ref ref : receivePackRefCache.byPrefix(RefNames.changeRefPrefix(toChange))) { | |
| try { | |
| PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName()); | |
| if (psId != null) { | |
| revisions.forcePut(receivePack.getRevWalk().parseCommit(ref.getObjectId()), psId); | |
| } | |
| } catch (IOException err) { | |
| logger.atWarning().withCause(err).log( | |
| "Project %s contains invalid change ref %s", project.getName(), ref.getName()); | |
| } | |
| } | |
| } | |
| /** | |
| * Validate the new patch set commit for this change. | |
| * | |
| * <p><strong>Side effects:</strong> | |
| * | |
| * <ul> | |
| * <li>May add error or warning messages to the progress monitor | |
| * <li>Will reject {@code cmd} prior to returning false | |
| * <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk. | |
| * </ul> | |
| * | |
| * @return whether the new commit is valid | |
| */ | |
| boolean validateNewPatchSet() throws IOException, PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("validateNewPatchSet")) { | |
| if (!validateNewPatchSetNoteDb()) { | |
| return false; | |
| } | |
| sameTreeWarning(); | |
| if (magicBranch != null) { | |
| validateMagicBranchWipStatusChange(); | |
| if (inputCommand.getResult() != NOT_ATTEMPTED) { | |
| return false; | |
| } | |
| if (magicBranch.edit) { | |
| return newEdit(); | |
| } | |
| } | |
| newPatchSet(); | |
| return true; | |
| } | |
| } | |
| boolean validateNewPatchSetForAutoClose() throws IOException, PermissionBackendException { | |
| if (!validateNewPatchSetNoteDb()) { | |
| return false; | |
| } | |
| newPatchSet(); | |
| return true; | |
| } | |
| /** Validates the new PS against permissions and notedb status. */ | |
| private boolean validateNewPatchSetNoteDb() throws IOException, PermissionBackendException { | |
| try (TraceTimer traceTimer = newTimer("validateNewPatchSetNoteDb")) { | |
| if (notes == null) { | |
| reject(inputCommand, "change " + ontoChange + " not found"); | |
| return false; | |
| } | |
| Change change = notes.getChange(); | |
| priorPatchSet = change.currentPatchSetId(); | |
| if (!revisions.containsValue(priorPatchSet)) { | |
| metrics.psRevisionMissing.increment(); | |
| logger.atWarning().log( | |
| "Change %d is missing revision for patch set %s" | |
| + " (it has revisions for these patch sets: %s)", | |
| change.getChangeId(), | |
| priorPatchSet.getId(), | |
| Iterables.toString( | |
| revisions.values().stream() | |
| .limit(100) // Enough for "normal" changes. | |
| .map(PatchSet.Id::getId) | |
| .collect(Collectors.toList()))); | |
| reject(inputCommand, "change " + ontoChange + " missing revisions"); | |
| return false; | |
| } | |
| RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId); | |
| // Not allowed to create a new patch set if the current patch set is locked. | |
| if (psUtil.isPatchSetLocked(notes)) { | |
| reject(inputCommand, "cannot add patch set to " + ontoChange + "."); | |
| return false; | |
| } | |
| if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) { | |
| reject(inputCommand, "cannot add patch set to " + ontoChange + "."); | |
| return false; | |
| } | |
| if (change.isClosed()) { | |
| reject(inputCommand, "change " + ontoChange + " closed"); | |
| return false; | |
| } else if (revisions.containsKey(newCommit)) { | |
| reject(inputCommand, "commit already exists (in the change)"); | |
| return false; | |
| } | |
| List<PatchSet.Id> existingPatchSetsWithSameCommit = | |
| receivePackRefCache.patchSetIdsFromObjectId(newCommit); | |
| if (!existingPatchSetsWithSameCommit.isEmpty()) { | |
| // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs | |
| // without the option to turn that off. | |
| reject( | |
| inputCommand, | |
| "commit already exists (in the project): " | |
| + existingPatchSetsWithSameCommit.get(0).toRefName()); | |
| return false; | |
| } | |
| try (TraceTimer traceTimer2 = newTimer("validateNewPatchSetNoteDb#isMergedInto")) { | |
| for (RevCommit prior : revisions.keySet()) { | |
| // Don't allow a change to directly depend upon itself. This is a | |
| // very common error due to users making a new commit rather than | |
| // amending when trying to address review comments. | |
| if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) { | |
| reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES); | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| } | |
| /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */ | |
| private void validateMagicBranchWipStatusChange() throws PermissionBackendException { | |
| Change change = notes.getChange(); | |
| if ((magicBranch.workInProgress || magicBranch.ready) | |
| && magicBranch.workInProgress != change.isWorkInProgress() | |
| && !user.getAccountId().equals(change.getOwner())) { | |
| if (!permissions.test(ProjectPermission.WRITE_CONFIG)) { | |
| if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) { | |
| reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP); | |
| } | |
| } | |
| } | |
| } | |
| /** prints a warning if the new PS has the same tree as the previous commit. */ | |
| private void sameTreeWarning() throws IOException { | |
| try (TraceTimer traceTimer = newTimer("sameTreeWarning")) { | |
| RevWalk rw = receivePack.getRevWalk(); | |
| RevCommit newCommit = rw.parseCommit(newCommitId); | |
| RevCommit priorCommit = revisions.inverse().get(priorPatchSet); | |
| if (newCommit.getTree().equals(priorCommit.getTree())) { | |
| rw.parseBody(newCommit); | |
| rw.parseBody(priorCommit); | |
| boolean messageEq = | |
| Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage()); | |
| boolean parentsEq = parentsEqual(newCommit, priorCommit); | |
| boolean authorEq = authorEqual(newCommit, priorCommit); | |
| ObjectReader reader = receivePack.getRevWalk().getObjectReader(); | |
| if (messageEq && parentsEq && authorEq) { | |
| addMessage( | |
| String.format( | |
| "warning: no changes between prior commit %s and new commit %s", | |
| abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader))); | |
| } else { | |
| StringBuilder msg = new StringBuilder(); | |
| msg.append("warning: ").append(abbreviateName(newCommit, reader)); | |
| msg.append(":"); | |
| msg.append(" no files changed"); | |
| if (!authorEq) { | |
| msg.append(", author changed"); | |
| } | |
| if (!messageEq) { | |
| msg.append(", message updated"); | |
| } | |
| if (!parentsEq) { | |
| msg.append(", was rebased"); | |
| } | |
| addMessage(msg.toString()); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Sets cmd and prev to the ReceiveCommands for change edits. Returns false if there was a | |
| * failure. | |
| */ | |
| private boolean newEdit() { | |
| try (TraceTimer traceTimer = newTimer("newEdit")) { | |
| psId = notes.getChange().currentPatchSetId(); | |
| Optional<ChangeEdit> edit; | |
| try { | |
| edit = editUtil.byChange(notes, user); | |
| } catch (AuthException | IOException e) { | |
| logger.atSevere().withCause(e).log("Cannot retrieve edit"); | |
| return false; | |
| } | |
| if (edit.isPresent()) { | |
| if (edit.get().getBasePatchSet().id().equals(psId)) { | |
| // replace edit | |
| cmd = | |
| new ReceiveCommand( | |
| edit.get().getEditCommit(), newCommitId, edit.get().getRefName()); | |
| } else { | |
| // delete old edit ref on rebase | |
| prev = | |
| new ReceiveCommand( | |
| edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName()); | |
| createEditCommand(); | |
| } | |
| } else { | |
| createEditCommand(); | |
| } | |
| return true; | |
| } | |
| } | |
| /** Creates a ReceiveCommand for a new edit. */ | |
| private void createEditCommand() { | |
| cmd = | |
| new ReceiveCommand( | |
| ObjectId.zeroId(), | |
| newCommitId, | |
| RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId)); | |
| } | |
| /** Updates 'this' to add a new patchset. */ | |
| private void newPatchSet() throws IOException { | |
| try (TraceTimer traceTimer = newTimer("newPatchSet")) { | |
| RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId); | |
| psId = nextPatchSetId(notes.getChange().currentPatchSetId()); | |
| info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId); | |
| cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName()); | |
| } | |
| } | |
| private PatchSet.Id nextPatchSetId(PatchSet.Id psId) throws IOException { | |
| PatchSet.Id next = ChangeUtil.nextPatchSetId(psId); | |
| while (receivePackRefCache.exactRef(next.toRefName()) != null) { | |
| next = ChangeUtil.nextPatchSetId(next); | |
| } | |
| return next; | |
| } | |
| void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException { | |
| try (TraceTimer traceTimer = newTimer("addOps")) { | |
| if (magicBranch != null && magicBranch.edit) { | |
| bu.addOp(notes.getChangeId(), new ReindexOnlyOp()); | |
| if (prev != null) { | |
| bu.addRepoOnlyOp(new UpdateOneRefOp(prev)); | |
| } | |
| bu.addRepoOnlyOp(new UpdateOneRefOp(cmd)); | |
| return; | |
| } | |
| RevWalk rw = receivePack.getRevWalk(); | |
| // TODO(dborowitz): Move to ReplaceOp#updateRepo. | |
| RevCommit newCommit = rw.parseCommit(newCommitId); | |
| rw.parseBody(newCommit); | |
| RevCommit priorCommit = revisions.inverse().get(priorPatchSet); | |
| replaceOp = | |
| replaceOpFactory.create( | |
| projectState, | |
| notes.getChange(), | |
| checkMergedInto, | |
| checkMergedInto ? inputCommand.getNewId().name() : null, | |
| priorPatchSet, | |
| priorCommit, | |
| psId, | |
| newCommit, | |
| info, | |
| groups, | |
| magicBranch, | |
| receivePack.getPushCertificate(), | |
| requestScopePropagator); | |
| bu.addOp(notes.getChangeId(), replaceOp); | |
| if (progress != null) { | |
| bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress)); | |
| } | |
| bu.addRepoOnlyOp( | |
| new RepoOnlyOp() { | |
| @Override | |
| public void updateRepo(RepoContext ctx) throws Exception { | |
| // Create auto merge ref if the new patch set is a merge commit. This is only | |
| // required for new patch sets on existing changes as these do not go through | |
| // PatchSetInserter. New changes pushed via git go through ChangeInserter and have | |
| // their auto merge commits created there. | |
| Optional<ReceiveCommand> autoMerge = | |
| autoMerger.createAutoMergeCommitIfNecessary( | |
| ctx.getRepoView(), | |
| ctx.getRevWalk(), | |
| ctx.getInserter(), | |
| ctx.getRevWalk().parseCommit(newCommitId)); | |
| if (autoMerge.isPresent()) { | |
| ctx.addRefUpdate(autoMerge.get()); | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| @Nullable | |
| String getRejectMessage() { | |
| return replaceOp != null ? replaceOp.getRejectMessage() : null; | |
| } | |
| Optional<String> getOutdatedApprovalsMessage() { | |
| return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty(); | |
| } | |
| } | |
| private class UpdateOneRefOp implements RepoOnlyOp { | |
| final ReceiveCommand cmd; | |
| private UpdateOneRefOp(ReceiveCommand cmd) { | |
| this.cmd = requireNonNull(cmd); | |
| } | |
| @Override | |
| public void updateRepo(RepoContext ctx) throws IOException { | |
| ctx.addRefUpdate(cmd); | |
| } | |
| @Override | |
| public void postUpdate(PostUpdateContext ctx) { | |
| String refName = cmd.getRefName(); | |
| if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward | |
| logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName()); | |
| tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId()); | |
| } | |
| if (isConfig(cmd)) { | |
| logger.atFine().log("Reloading project in cache"); | |
| projectCache.evictAndReindex(project); | |
| ProjectState ps = | |
| projectCache.get(project.getNameKey()).orElseThrow(illegalState(project.getNameKey())); | |
| try { | |
| logger.atFine().log("Updating project description"); | |
| repo.setGitwebDescription(ps.getProject().getDescription()); | |
| } catch (IOException e) { | |
| throw new StorageException("cannot update description of " + project.getName(), e); | |
| } | |
| if (allProjectsName.equals(project.getNameKey())) { | |
| try { | |
| createGroupPermissionSyncer.syncIfNeeded(); | |
| } catch (IOException | ConfigInvalidException e) { | |
| throw new StorageException("cannot update description of " + project.getName(), e); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private static class ReindexOnlyOp implements BatchUpdateOp { | |
| @Override | |
| public boolean updateChange(ChangeContext ctx) { | |
| // Trigger reindexing even though change isn't actually updated. | |
| return true; | |
| } | |
| } | |
| private static boolean parentsEqual(RevCommit a, RevCommit b) { | |
| if (a.getParentCount() != b.getParentCount()) { | |
| return false; | |
| } | |
| for (int i = 0; i < a.getParentCount(); i++) { | |
| if (!a.getParent(i).equals(b.getParent(i))) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private static boolean authorEqual(RevCommit a, RevCommit b) { | |
| PersonIdent aAuthor = a.getAuthorIdent(); | |
| PersonIdent bAuthor = b.getAuthorIdent(); | |
| if (aAuthor == null && bAuthor == null) { | |
| return true; | |
| } else if (aAuthor == null || bAuthor == null) { | |
| return false; | |
| } | |
| return Objects.equals(aAuthor.getName(), bAuthor.getName()) | |
| && Objects.equals(aAuthor.getEmailAddress(), bAuthor.getEmailAddress()); | |
| } | |
| // Run RefValidators on the command. If any validator fails, the command status is set to | |
| // REJECTED, and the return value is 'false' | |
| private boolean validRefOperation(ReceiveCommand cmd) { | |
| try (TraceTimer traceTimer = newTimer("validRefOperation")) { | |
| RefOperationValidators refValidators = | |
| refValidatorsFactory.create( | |
| getProject(), user, cmd, ImmutableListMultimap.copyOf(pushOptions)); | |
| try { | |
| messages.addAll(refValidators.validateForRefOperation()); | |
| } catch (RefOperationValidationException e) { | |
| messages.addAll(e.getMessages()); | |
| reject(cmd, e.getMessage()); | |
| return false; | |
| } | |
| return true; | |
| } | |
| } | |
| /** | |
| * Validates the commits that a regular push brings in. | |
| * | |
| * <p>On validation failure, the command is rejected. | |
| */ | |
| private void validateRegularPushCommits(BranchNameKey branch, ReceiveCommand cmd) | |
| throws PermissionBackendException { | |
| try (TraceTimer traceTimer = | |
| newTimer("validateRegularPushCommits", Metadata.builder().branchName(branch.branch()))) { | |
| boolean skipValidation = | |
| !RefNames.REFS_CONFIG.equals(cmd.getRefName()) | |
| && !(MagicBranch.isMagicBranch(cmd.getRefName()) | |
| || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches()) | |
| && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION); | |
| if (skipValidation) { | |
| if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) { | |
| reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION); | |
| return; | |
| } | |
| Optional<AuthException> err = | |
| checkRefPermission(permissions.ref(branch.branch()), RefPermission.SKIP_VALIDATION); | |
| if (err.isPresent()) { | |
| rejectProhibited(cmd, err.get()); | |
| return; | |
| } | |
| if (!Iterables.isEmpty(rejectCommits)) { | |
| reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION); | |
| } | |
| } | |
| BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user); | |
| RevWalk walk = receivePack.getRevWalk(); | |
| walk.reset(); | |
| walk.sort(RevSort.NONE); | |
| try { | |
| RevObject parsedObject = walk.parseAny(cmd.getNewId()); | |
| if (!(parsedObject instanceof RevCommit)) { | |
| return; | |
| } | |
| walk.markStart((RevCommit) parsedObject); | |
| markHeadsAsUninteresting(walk, cmd.getRefName()); | |
| int limit = receiveConfig.maxBatchCommits; | |
| int n = 0; | |
| for (RevCommit c; (c = walk.next()) != null; ) { | |
| // Even if skipValidation is set, we still get here when at least one plugin | |
| // commit validator requires to validate all commits. In this case, however, | |
| // we don't need to check the commit limit. | |
| if (++n > limit && !skipValidation) { | |
| logger.atFine().log("Number of new commits exceeds limit of %d", limit); | |
| reject( | |
| cmd, | |
| String.format( | |
| "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION)); | |
| return; | |
| } | |
| if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) { | |
| continue; | |
| } | |
| BranchCommitValidator.Result validationResult = | |
| validator.validateCommit( | |
| repo, | |
| walk.getObjectReader(), | |
| cmd, | |
| c, | |
| ImmutableListMultimap.copyOf(pushOptions), | |
| false, | |
| rejectCommits, | |
| null, | |
| skipValidation); | |
| messages.addAll(validationResult.messages()); | |
| if (!validationResult.isValid()) { | |
| break; | |
| } | |
| } | |
| logger.atFine().log("Validated %d new commits", n); | |
| } catch (IOException err) { | |
| cmd.setResult(REJECTED_MISSING_OBJECT); | |
| logger.atSevere().withCause(err).log( | |
| "Invalid pack upload; one or more objects weren't sent"); | |
| } | |
| } | |
| } | |
| private void autoCloseChanges(ReceiveCommand cmd, Task progress) { | |
| try (TraceTimer traceTimer = newTimer("autoCloseChanges")) { | |
| logger.atFine().log("Starting auto-closing of changes"); | |
| String refName = cmd.getRefName(); | |
| Set<Change.Id> ids = new HashSet<>(); | |
| // TODO(dborowitz): Combine this BatchUpdate with the main one in | |
| // handleRegularCommands | |
| try { | |
| retryHelper | |
| .changeUpdate( | |
| "autoCloseChanges", | |
| updateFactory -> { | |
| try (BatchUpdate bu = | |
| updateFactory.create(projectState.getNameKey(), user, TimeUtil.now()); | |
| ObjectInserter ins = repo.newObjectInserter(); | |
| ObjectReader reader = ins.newReader(); | |
| RevWalk rw = new RevWalk(reader)) { | |
| if (ObjectId.zeroId().equals(cmd.getOldId())) { | |
| // The user is creating a new branch. The branch can't contain any changes, so | |
| // auto-closing doesn't apply. Exiting here early to spare any further, | |
| // potentially expensive computation that loop over all commits. | |
| return null; | |
| } | |
| bu.setRepository(repo, rw, ins); | |
| // TODO(dborowitz): Teach BatchUpdate to ignore missing changes. | |
| RevCommit newTip = rw.parseCommit(cmd.getNewId()); | |
| BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName); | |
| rw.reset(); | |
| rw.sort(RevSort.REVERSE); | |
| rw.markStart(newTip); | |
| rw.markUninteresting(rw.parseCommit(cmd.getOldId())); | |
| Map<Change.Key, ChangeData> changeDataByKey = null; | |
| List<ReplaceRequest> replaceAndClose = new ArrayList<>(); | |
| int existingPatchSets = 0; | |
| int newPatchSets = 0; | |
| SubmissionId submissionId = null; | |
| COMMIT: | |
| for (RevCommit c; (c = rw.next()) != null; ) { | |
| rw.parseBody(c); | |
| // Check if change refs point to this commit. Usually there are 0-1 change | |
| // refs pointing to this commit. | |
| for (PatchSet.Id psId : | |
| receivePackRefCache.patchSetIdsFromObjectId(c.copy())) { | |
| Optional<ChangeNotes> notes = getChangeNotes(psId.changeId()); | |
| if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) { | |
| if (submissionId == null) { | |
| submissionId = new SubmissionId(notes.get().getChange()); | |
| } | |
| existingPatchSets++; | |
| bu.addOp( | |
| notes.get().getChangeId(), setPrivateOpFactory.create(false, null)); | |
| bu.addOp( | |
| psId.changeId(), | |
| mergedByPushOpFactory.create( | |
| requestScopePropagator, | |
| psId, | |
| submissionId, | |
| refName, | |
| newTip.getId().getName())); | |
| continue COMMIT; | |
| } | |
| } | |
| for (String changeId : changeUtil.getChangeIdsFromFooter(c)) { | |
| if (changeDataByKey == null) { | |
| changeDataByKey = | |
| retryHelper | |
| .changeIndexQuery( | |
| "queryOpenChangesByKeyByBranch", | |
| q -> openChangesByKeyByBranch(q, branch)) | |
| .call(); | |
| } | |
| ChangeData onto = changeDataByKey.get(Change.key(changeId.trim())); | |
| if (onto != null) { | |
| newPatchSets++; | |
| // Hold onto this until we're done with the walk, as the call to | |
| // req.validate below calls isMergedInto which resets the walk. | |
| ChangeNotes ontoNotes = onto.notes(); | |
| ReplaceRequest req = | |
| new ReplaceRequest(ontoNotes.getChangeId(), c, cmd, false); | |
| req.notes = ontoNotes; | |
| replaceAndClose.add(req); | |
| continue COMMIT; | |
| } | |
| } | |
| } | |
| for (ReplaceRequest req : replaceAndClose) { | |
| Change.Id id = req.notes.getChangeId(); | |
| if (!req.validateNewPatchSetForAutoClose()) { | |
| logger.atFine().log("Not closing %s because validation failed", id); | |
| continue; | |
| } | |
| if (submissionId == null) { | |
| submissionId = new SubmissionId(req.notes.getChange()); | |
| } | |
| req.addOps(bu, null); | |
| bu.addOp(id, setPrivateOpFactory.create(false, null)); | |
| bu.addOp( | |
| id, | |
| mergedByPushOpFactory | |
| .create( | |
| requestScopePropagator, | |
| req.psId, | |
| submissionId, | |
| refName, | |
| newTip.getId().getName()) | |
| .setPatchSetProvider(req.replaceOp::getPatchSet)); | |
| bu.addOp(id, new ChangeProgressOp(progress)); | |
| ids.add(id); | |
| } | |
| logger.atFine().log( | |
| "Auto-closing %d changes with existing patch sets and %d with new patch" | |
| + " sets", | |
| existingPatchSets, newPatchSets); | |
| bu.execute(); | |
| } catch (IOException | StorageException | PermissionBackendException e) { | |
| throw new StorageException("Failed to auto-close changes", e); | |
| } | |
| // If we are here, we didn't throw UpdateException. Record the result. | |
| // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id | |
| // doesn't | |
| // fit into TreeSet. | |
| ids.stream() | |
| .forEach( | |
| id -> result.addChange(ReceiveCommitsResult.ChangeStatus.AUTOCLOSED, id)); | |
| return null; | |
| }) | |
| // Use a multiple of the default timeout to account for inner retries that may otherwise | |
| // eat up the whole timeout so that no time is left to retry this outer action. | |
| .defaultTimeoutMultiplier(5) | |
| .call(); | |
| } catch (RestApiException e) { | |
| logger.atSevere().withCause(e).log("Can't insert patchset"); | |
| } catch (UpdateException e) { | |
| logger.atSevere().withCause(e).log("Failed to auto-close changes"); | |
| } finally { | |
| logger.atFine().log("Done auto-closing changes"); | |
| } | |
| } | |
| } | |
| private Optional<ChangeNotes> getChangeNotes(Change.Id changeId) { | |
| try { | |
| return Optional.of(notesFactory.createChecked(project.getNameKey(), changeId)); | |
| } catch (NoSuchChangeException e) { | |
| return Optional.empty(); | |
| } | |
| } | |
| private Map<Change.Key, ChangeData> openChangesByKeyByBranch( | |
| InternalChangeQuery internalChangeQuery, BranchNameKey branch) { | |
| try (TraceTimer traceTimer = | |
| newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) { | |
| Map<Change.Key, ChangeData> r = new HashMap<>(); | |
| for (ChangeData cd : internalChangeQuery.byBranchOpen(branch)) { | |
| try { | |
| // ChangeData is not materialised into a ChangeNotes for avoiding | |
| // to load a potentially large number of changes meta-data into memory | |
| // which would cause unnecessary disk I/O, CPU and heap utilisation. | |
| r.put(cd.change().getKey(), cd); | |
| } catch (NoSuchChangeException e) { | |
| // Ignore deleted change | |
| } | |
| } | |
| return r; | |
| } | |
| } | |
| private TraceTimer newTimer(String name) { | |
| return newTimer(getClass(), name); | |
| } | |
| private TraceTimer newTimer(Class<?> clazz, String name) { | |
| return newTimer(clazz, name, Metadata.builder()); | |
| } | |
| private TraceTimer newTimer(String name, Metadata.Builder metadataBuilder) { | |
| return newTimer(getClass(), name, metadataBuilder); | |
| } | |
| private TraceTimer newTimer(Class<?> clazz, String name, Metadata.Builder metadataBuilder) { | |
| metadataBuilder.projectName(project.getName()); | |
| return TraceContext.newTimer(clazz.getSimpleName() + "#" + name, metadataBuilder.build()); | |
| } | |
| private static void reject(ReceiveCommand cmd, String why) { | |
| logger.atFine().log("Rejecting command '%s': %s", cmd, why); | |
| cmd.setResult(REJECTED_OTHER_REASON, why); | |
| } | |
| private static void rejectRemaining(Collection<ReceiveCommand> commands, String why) { | |
| rejectRemaining(commands.stream(), why); | |
| } | |
| private static void rejectRemaining(Stream<ReceiveCommand> commands, String why) { | |
| commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, why)); | |
| } | |
| private static boolean isHead(ReceiveCommand cmd) { | |
| return cmd.getRefName().startsWith(Constants.R_HEADS); | |
| } | |
| private static boolean isConfig(ReceiveCommand cmd) { | |
| return cmd.getRefName().equals(RefNames.REFS_CONFIG); | |
| } | |
| private static String commandToString(ReceiveCommand cmd) { | |
| StringBuilder b = new StringBuilder(); | |
| b.append(cmd); | |
| b.append(" (").append(cmd.getResult()); | |
| if (cmd.getMessage() != null) { | |
| b.append(": ").append(cmd.getMessage()); | |
| } | |
| b.append(")\n"); | |
| return b.toString(); | |
| } | |
| } |