| // Copyright (C) 2013 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.notedb; |
| |
| import static com.google.common.base.MoreObjects.firstNonNull; |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.gerrit.entities.RefNames.changeMetaRef; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC; |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS; |
| import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter; |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; |
| import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION; |
| import static java.util.Comparator.naturalOrder; |
| import static java.util.Objects.requireNonNull; |
| import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Joiner; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.ImmutableTable; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.Table; |
| import com.google.common.collect.Table.Cell; |
| import com.google.common.collect.TreeBasedTable; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.Address; |
| import com.google.gerrit.entities.AttentionSetUpdate; |
| import com.google.gerrit.entities.AttentionSetUpdate.Operation; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.Comment; |
| import com.google.gerrit.entities.HumanComment; |
| import com.google.gerrit.entities.LabelId; |
| import com.google.gerrit.entities.PatchSet; |
| import com.google.gerrit.entities.PatchSetApproval; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.entities.RobotComment; |
| import com.google.gerrit.entities.SubmissionId; |
| import com.google.gerrit.entities.SubmitRecord; |
| import com.google.gerrit.entities.SubmitRequirementResult; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.extensions.client.ReviewerState; |
| import com.google.gerrit.server.ChangeDraftUpdate; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.account.ServiceUserClassifier; |
| import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator; |
| import com.google.gerrit.server.git.validators.TopicValidator; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.update.context.RefUpdateContext; |
| import com.google.gerrit.server.util.AttentionSetUtil; |
| import com.google.gerrit.server.util.LabelVote; |
| import com.google.gerrit.server.validators.ValidationException; |
| import com.google.inject.assistedinject.Assisted; |
| import com.google.inject.assistedinject.AssistedInject; |
| import java.io.IOException; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.TreeMap; |
| import java.util.stream.Collectors; |
| import java.util.stream.Stream; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.ObjectInserter; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.notes.NoteMap; |
| import org.eclipse.jgit.revwalk.FooterKey; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| |
| /** |
| * A delta to apply to a change. |
| * |
| * <p>This delta will become two unique commits: one in the AllUsers repo that will contain the |
| * draft comments on this change and one in the notes branch that will contain approvals, reviewers, |
| * change status, subject, submit records, the change message, and published comments. There are |
| * limitations on the set of modifications that can be handled in a single update. In particular, |
| * there is a single author and timestamp for each update. |
| * |
| * <p>This class is not thread-safe. |
| * |
| * <p>NOTE: This class also serializes the change in a custom storage format, used in NoteDB. All |
| * changes to the storage format must be both forward and backward compatible, see comment on {@link |
| * ChangeNotesParser}. |
| * |
| * <p>Such changes include e.g. introducing/removing footers, modifying footer formats, mutations of |
| * the attached {@link ChangeRevisionNote}. |
| */ |
| public class ChangeUpdate extends AbstractChangeUpdate { |
| public interface Factory { |
| ChangeUpdate create(ChangeNotes notes, CurrentUser user, Instant when); |
| |
| ChangeUpdate create( |
| ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator); |
| } |
| |
| public static final int MAX_CUSTOM_KEY_LENGTH = 100; |
| public static final int MAX_CUSTOM_KEYED_VALUE_LENGTH = 1000; |
| public static final int MAX_CUSTOM_KEYED_VALUES = 100; |
| |
| private final NoteDbUpdateManager.Factory updateManagerFactory; |
| private final ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory; |
| private final RobotCommentUpdate.Factory robotCommentUpdateFactory; |
| private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory; |
| private final ServiceUserClassifier serviceUserClassifier; |
| private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator; |
| |
| private final Table<String, Account.Id, Optional<PatchSetApproval>> approvals; |
| private final List<PatchSetApproval> copiedApprovals = new ArrayList<>(); |
| private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>(); |
| private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>(); |
| private final List<HumanComment> comments = new ArrayList<>(); |
| |
| private String commitSubject; |
| private String subject; |
| private String changeId; |
| private String branch; |
| private Change.Status status; |
| private List<SubmitRecord> submitRecords; |
| private String submissionId; |
| private String topic; |
| private String commit; |
| private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates; |
| private boolean ignoreFurtherAttentionSetUpdates; |
| private Set<String> hashtags; |
| private TreeMap<String, String> customKeyedValues = new TreeMap<>(); |
| private String changeMessage; |
| private String tag; |
| private PatchSetState psState; |
| private List<String> groups; |
| private String pushCert; |
| private boolean isAllowWriteToNewtRef; |
| private String psDescription; |
| private boolean currentPatchSet; |
| private Boolean isPrivate; |
| private Boolean workInProgress; |
| private Integer revertOf; |
| // If null, the update does not modify the field. Otherwise, it updates the field with the |
| // new value or resets if cherryPickOf == Optional.empty(). |
| private Optional<String> cherryPickOf; |
| |
| private ChangeDraftUpdate draftUpdate; |
| private RobotCommentUpdate robotCommentUpdate; |
| private DeleteCommentRewriter deleteCommentRewriter; |
| private DeleteChangeMessageRewriter deleteChangeMessageRewriter; |
| private List<SubmitRequirementResult> submitRequirementResults; |
| |
| private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder = |
| ImmutableList.builder(); |
| |
| private final CurrentUser user; |
| |
| @SuppressWarnings("UnusedMethod") |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritPersonIdent PersonIdent serverIdent, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| DeleteCommentRewriter.Factory deleteCommentRewriterFactory, |
| ProjectCache projectCache, |
| ServiceUserClassifier serviceUserClassifier, |
| PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator, |
| @Assisted ChangeNotes notes, |
| @Assisted CurrentUser user, |
| @Assisted Instant when, |
| ChangeNoteUtil noteUtil) { |
| this( |
| serverIdent, |
| updateManagerFactory, |
| draftUpdateFactory, |
| robotCommentUpdateFactory, |
| deleteCommentRewriterFactory, |
| serviceUserClassifier, |
| patchSetApprovalUuidGenerator, |
| notes, |
| user, |
| when, |
| projectCache |
| .get(notes.getProjectName()) |
| .orElseThrow(illegalState(notes.getProjectName())) |
| .getLabelTypes() |
| .nameComparator(), |
| noteUtil); |
| } |
| |
| private static Table<String, Account.Id, Optional<PatchSetApproval>> approvals( |
| Comparator<String> nameComparator) { |
| return TreeBasedTable.create(nameComparator, naturalOrder()); |
| } |
| |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritPersonIdent PersonIdent serverIdent, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| DeleteCommentRewriter.Factory deleteCommentRewriterFactory, |
| ServiceUserClassifier serviceUserClassifier, |
| PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator, |
| @Assisted ChangeNotes notes, |
| @Assisted CurrentUser user, |
| @Assisted Instant when, |
| @Assisted Comparator<String> labelNameComparator, |
| ChangeNoteUtil noteUtil) { |
| super(notes, user, serverIdent, noteUtil, when); |
| this.updateManagerFactory = updateManagerFactory; |
| this.draftUpdateFactory = draftUpdateFactory; |
| this.robotCommentUpdateFactory = robotCommentUpdateFactory; |
| this.deleteCommentRewriterFactory = deleteCommentRewriterFactory; |
| this.serviceUserClassifier = serviceUserClassifier; |
| this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator; |
| this.approvals = approvals(labelNameComparator); |
| this.user = user; |
| } |
| |
| public ObjectId commit() throws IOException { |
| try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { |
| try (NoteDbUpdateManager updateManager = |
| updateManagerFactory.create(getProjectName(), user)) { |
| updateManager.add(this); |
| updateManager.execute(); |
| } |
| } |
| return getResult(); |
| } |
| |
| public void setChangeId(String changeId) { |
| String old = getChange().getKey().get(); |
| checkArgument( |
| old.equals(changeId), |
| "The Change-Id was already set to %s, so we cannot set this Change-Id: %s", |
| old, |
| changeId); |
| this.changeId = changeId; |
| } |
| |
| public void setBranch(String branch) { |
| this.branch = branch; |
| } |
| |
| public void setStatus(Change.Status status) { |
| checkArgument(status != Change.Status.MERGED, "use merge(RequestId, Iterable<SubmitRecord>)"); |
| this.status = status; |
| } |
| |
| public void fixStatusToMerged(SubmissionId submissionId) { |
| checkArgument(submissionId != null, "submission id must be set for merged changes"); |
| this.status = Change.Status.MERGED; |
| this.submissionId = submissionId.toString(); |
| } |
| |
| public void putApproval(String label, short value) { |
| putApprovalFor(getAccountId(), label, value); |
| } |
| |
| public void putApprovalFor(Account.Id reviewer, String label, short value) { |
| PatchSetApproval psa = |
| PatchSetApproval.builder() |
| .key(PatchSetApproval.key(getPatchSetId(), reviewer, LabelId.create(label))) |
| .value(value) |
| .granted(when) |
| .uuid(patchSetApprovalUuidGenerator.get(getPatchSetId(), reviewer, label, value, when)) |
| .build(); |
| approvals.put(label, reviewer, Optional.of(psa)); |
| } |
| |
| public ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> getApprovals() { |
| return ImmutableTable.copyOf(approvals); |
| } |
| |
| void removeApproval(String label) { |
| removeApprovalFor(getAccountId(), label); |
| } |
| |
| public void removeApprovalFor(Account.Id reviewer, String label) { |
| approvals.put(label, reviewer, Optional.empty()); |
| } |
| |
| /** |
| * We expect the {@code copied} flag of {@code copiedPatchSetApproval} to be set, since this |
| * method is only meant for copied approvals. |
| */ |
| public void putCopiedApproval(PatchSetApproval copiedPatchSetApproval) { |
| checkArgument(copiedPatchSetApproval.copied(), "Approval that should be copied is not copied."); |
| copiedApprovals.add(copiedPatchSetApproval); |
| } |
| |
| public void removeCopiedApprovalFor( |
| @Nullable Account.Id realUserId, Account.Id reviewerId, String label) { |
| PatchSetApproval.Builder psaBuilder = |
| PatchSetApproval.builder() |
| .copied(true) |
| .key(PatchSetApproval.key(getPatchSetId(), reviewerId, LabelId.create(label))) |
| .value(0) |
| .uuid(Optional.empty()) |
| .granted(when); |
| |
| if (realUserId != null) { |
| psaBuilder.realAccountId(realUserId); |
| } |
| |
| copiedApprovals.add(psaBuilder.build()); |
| } |
| |
| public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) { |
| this.status = Change.Status.MERGED; |
| this.submissionId = submissionId.toString(); |
| this.submitRecords = ImmutableList.copyOf(submitRecords); |
| checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time"); |
| } |
| |
| public void setSubjectForCommit(String commitSubject) { |
| this.commitSubject = commitSubject; |
| } |
| |
| public void setSubject(String subject) { |
| this.subject = subject; |
| } |
| |
| @VisibleForTesting |
| ObjectId getCommit() { |
| return ObjectId.fromString(commit); |
| } |
| |
| public void setChangeMessage(String changeMessage) { |
| this.changeMessage = changeMessage; |
| } |
| |
| public void setTag(String tag) { |
| this.tag = tag; |
| } |
| |
| public void setPsDescription(String psDescription) { |
| this.psDescription = psDescription; |
| } |
| |
| public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) { |
| if (submitRequirementResults == null) { |
| submitRequirementResults = new ArrayList<>(); |
| } |
| submitRequirementResults.addAll(rs); |
| } |
| |
| public void putComment(Comment.Status status, HumanComment c) { |
| verifyComment(c); |
| createDraftUpdateIfNull(); |
| if (status == HumanComment.Status.DRAFT) { |
| draftUpdate.putDraftComment(c); |
| } else { |
| comments.add(c); |
| draftUpdate.markDraftCommentAsPublished(c); |
| } |
| } |
| |
| public void putRobotComment(RobotComment c) { |
| verifyComment(c); |
| createRobotCommentUpdateIfNull(); |
| robotCommentUpdate.putComment(c); |
| } |
| |
| public void deleteComment(HumanComment c) { |
| verifyComment(c); |
| createDraftUpdateIfNull().addDraftCommentForDeletion(c); |
| } |
| |
| public void deleteCommentByRewritingHistory(String uuid, String newMessage) { |
| deleteCommentRewriter = |
| deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage); |
| } |
| |
| public void deleteChangeMessageByRewritingHistory(String targetMessageId, String newMessage) { |
| deleteChangeMessageRewriter = |
| new DeleteChangeMessageRewriter(getChange().getId(), targetMessageId, newMessage); |
| } |
| |
| @VisibleForTesting |
| ChangeDraftUpdate createDraftUpdateIfNull() { |
| if (draftUpdate == null) { |
| ChangeNotes notes = getNotes(); |
| if (notes != null) { |
| draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when); |
| } else { |
| // tests will always take the notes != null path above. |
| draftUpdate = |
| draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when); |
| } |
| } |
| return draftUpdate; |
| } |
| |
| private void createRobotCommentUpdateIfNull() { |
| if (robotCommentUpdate == null) { |
| ChangeNotes notes = getNotes(); |
| if (notes != null) { |
| robotCommentUpdate = |
| robotCommentUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when); |
| } else { |
| robotCommentUpdate = |
| robotCommentUpdateFactory.create( |
| getChange(), accountId, realAccountId, authorIdent, when); |
| } |
| } |
| } |
| |
| public void setTopic(String topic, TopicValidator validator) throws ValidationException { |
| validator.validateSize(topic); |
| if (isIllegalTopic(topic)) { |
| throw new ValidationException("topic can't contain quotation marks."); |
| } |
| this.topic = Strings.nullToEmpty(topic); |
| } |
| |
| public void setCommit(RevWalk rw, ObjectId id) throws IOException { |
| setCommit(rw, id, null); |
| } |
| |
| public void setCommit(RevWalk rw, ObjectId id, String pushCert) throws IOException { |
| RevCommit commit = rw.parseCommit(id); |
| rw.parseBody(commit); |
| this.commit = commit.name(); |
| subject = commit.getShortMessage(); |
| this.pushCert = pushCert; |
| } |
| |
| public void setHashtags(Set<String> hashtags) { |
| this.hashtags = hashtags; |
| } |
| |
| public void addCustomKeyedValue(String key, String value) throws ValidationException { |
| if (key.length() > MAX_CUSTOM_KEY_LENGTH) { |
| throw new ValidationException("Custom Key is too long."); |
| } |
| if (value.length() > MAX_CUSTOM_KEYED_VALUE_LENGTH) { |
| throw new ValidationException("Custom Keyed value is too long."); |
| } |
| this.customKeyedValues.put(key, value); |
| } |
| |
| public void deleteCustomKeyedValue(String key) throws ValidationException { |
| if (key.length() > MAX_CUSTOM_KEY_LENGTH) { |
| throw new ValidationException("Custom Key is too long."); |
| } |
| this.customKeyedValues.put(key, ""); |
| } |
| |
| /** |
| * Adds attention set updates that should be stored in NoteDb. |
| * |
| * <p>If invoked multiple times with attention set updates for the same user, only the attention |
| * set update of the first invocation is stored for this user and further attention set updates |
| * for this user are silently ignored. This means if callers invoke this method multiple times |
| * with attention set updates for the same user, they must ensure that the first call is being |
| * done with the attention set update that should take precedence. |
| * |
| * @param updates Attention set updates that should be performed. The updates must not have any |
| * timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is |
| * because the timestamp of all performed updates is always the timestamp of when the NoteDb |
| * commit is created. Each of the provided updates must be for a different user, if there are |
| * multiple updates for the same user the update is rejected. |
| * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or |
| * if the provided set of updates contains multiple updates for the same user |
| */ |
| public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) { |
| if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) { |
| // No updates to do. Robots don't change attention set. |
| return; |
| } |
| checkArgument( |
| updates.stream().noneMatch(a -> a.timestamp() != null), |
| "must not specify timestamp for write"); |
| |
| checkArgument( |
| updates.stream().map(AttentionSetUpdate::account).distinct().count() == updates.size(), |
| "must not specify multiple updates for single user"); |
| |
| if (plannedAttentionSetUpdates == null) { |
| plannedAttentionSetUpdates = new HashMap<>(); |
| } |
| |
| Set<Account.Id> currentAccountUpdates = |
| plannedAttentionSetUpdates.values().stream() |
| .map(AttentionSetUpdate::account) |
| .collect(Collectors.toSet()); |
| updates.stream() |
| .filter(u -> !currentAccountUpdates.contains(u.account())) |
| .forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u)); |
| } |
| |
| public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) { |
| addToPlannedAttentionSetUpdates(ImmutableSet.of(update)); |
| } |
| |
| public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() { |
| return attentionSetUpdatesBuilder.build(); |
| } |
| |
| public Map<Account.Id, ReviewerStateInternal> getReviewers() { |
| return reviewers; |
| } |
| |
| public void putReviewer(Account.Id reviewer, ReviewerStateInternal type) { |
| checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType"); |
| reviewers.put(reviewer, type); |
| } |
| |
| public void removeReviewer(Account.Id reviewer) { |
| reviewers.put(reviewer, ReviewerStateInternal.REMOVED); |
| } |
| |
| public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) { |
| checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType"); |
| reviewersByEmail.put(reviewer, type); |
| } |
| |
| public void removeReviewerByEmail(Address reviewer) { |
| reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED); |
| } |
| |
| public void setPatchSetState(PatchSetState psState) { |
| this.psState = psState; |
| } |
| |
| public void setCurrentPatchSet() { |
| this.currentPatchSet = true; |
| } |
| |
| public void setGroups(List<String> groups) { |
| requireNonNull(groups, "groups may not be null"); |
| this.groups = groups; |
| } |
| |
| public void setRevertOf(int revertOf) { |
| int ownId = getId().get(); |
| checkArgument(ownId != revertOf, "A change cannot revert itself"); |
| this.revertOf = revertOf; |
| rootOnly = true; |
| } |
| |
| public void setCherryPickOf(String cherryPickOf) { |
| checkArgument(cherryPickOf != null, "use resetCherryPickOf"); |
| this.cherryPickOf = Optional.of(cherryPickOf); |
| } |
| |
| public void resetCherryPickOf() { |
| this.cherryPickOf = Optional.empty(); |
| } |
| |
| /** Returns the tree id for the updated tree */ |
| @Nullable |
| private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr) |
| throws ConfigInvalidException, IOException { |
| if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) { |
| return null; |
| } |
| RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr); |
| |
| RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm); |
| for (HumanComment c : comments) { |
| c.tag = tag; |
| cache.get(c.getCommitId()).putComment(c); |
| } |
| if (submitRequirementResults != null) { |
| if (submitRequirementResults.isEmpty()) { |
| ObjectId latestPsCommitId = |
| Iterables.getLast(getNotes().getPatchSets().values()).commitId(); |
| cache.get(latestPsCommitId).createEmptySubmitRequirementResults(); |
| } else { |
| // Clear any previously stored SRs first. The SRs in this update will overwrite any |
| // previously stored SRs (e.g. if the change is abandoned (SRs stored) -> un-abandoned -> |
| // merged). |
| submitRequirementResults.stream() |
| .map(SubmitRequirementResult::patchSetCommitId) |
| .distinct() |
| .forEach(commit -> cache.get(commit).clearSubmitRequirementResults()); |
| for (SubmitRequirementResult sr : submitRequirementResults) { |
| cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr); |
| } |
| } |
| } |
| if (pushCert != null) { |
| checkState(commit != null); |
| cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert); |
| } |
| Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders(); |
| checkComments(rnm.revisionNotes, builders); |
| |
| for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) { |
| ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson())); |
| rnm.noteMap.set(e.getKey(), data); |
| } |
| |
| return rnm.noteMap.writeTree(inserter); |
| } |
| |
| private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr) |
| throws ConfigInvalidException, IOException { |
| if (curr.equals(ObjectId.zeroId())) { |
| return RevisionNoteMap.emptyMap(); |
| } |
| // The old ChangeNotes may have already parsed the revision notes. We can reuse them as long as |
| // the ref hasn't advanced. |
| ChangeNotes notes = getNotes(); |
| if (notes != null && notes.revisionNoteMap != null) { |
| ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId()); |
| if (idFromNotes.equals(curr)) { |
| return notes.revisionNoteMap; |
| } |
| } |
| NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr)); |
| // Even though reading from changes might not be enabled, we need to |
| // parse any existing revision notes so we can merge them. |
| return RevisionNoteMap.parse( |
| noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.PUBLISHED); |
| } |
| |
| private void checkComments( |
| Map<ObjectId, ChangeRevisionNote> existingNotes, |
| Map<ObjectId, RevisionNoteBuilder> toUpdate) { |
| // Prohibit various kinds of illegal operations on comments. |
| Set<Comment.Key> existing = new HashSet<>(); |
| List<Comment> draftsToFix = new ArrayList<>(); |
| for (ChangeRevisionNote rn : existingNotes.values()) { |
| for (Comment c : rn.getEntities()) { |
| existing.add(c.key); |
| draftsToFix.add(c); |
| } |
| } |
| if (draftUpdate != null) { |
| // Take advantage of an existing update on All-Users to prune any |
| // published comments from drafts. NoteDbUpdateManager takes care of |
| // ensuring that this update is applied before its dependent draft |
| // update. |
| // |
| // Deleting aggressively in this way, combined with filtering out |
| // duplicate published/draft comments in ChangeNotes#getDraftsByChangeAndDraftAuthor, |
| // makes up for the fact that updates between the change repo and |
| // All-Users are not atomic. |
| // |
| // TODO(dborowitz): We might want to distinguish between deleted |
| // drafts that we're fixing up after the fact by putting them in a |
| // separate commit. But note that we don't care much about the commit |
| // graph of the draft ref, particularly because the ref is completely |
| // deleted when all drafts are gone. |
| draftUpdate.addAllDraftCommentsForDeletion(draftsToFix); |
| } |
| |
| for (RevisionNoteBuilder b : toUpdate.values()) { |
| for (Comment c : b.put.values()) { |
| if (existing.contains(c.key)) { |
| throw new StorageException("Cannot update existing published comment: " + c); |
| } |
| } |
| } |
| } |
| |
| @Override |
| protected String getRefName() { |
| return changeMetaRef(getId()); |
| } |
| |
| @Override |
| protected boolean bypassMaxUpdates() { |
| return isAbandonChange() || isAttentionSetChangeOnly(); |
| } |
| |
| private boolean isAbandonChange() { |
| return status != null && status.isClosed(); |
| } |
| |
| private boolean isAttentionSetChangeOnly() { |
| return (plannedAttentionSetUpdates != null |
| && plannedAttentionSetUpdates.size() > 0 |
| && doesNotHaveChangesAffectingAttentionSet()); |
| } |
| |
| private boolean doesNotHaveChangesAffectingAttentionSet() { |
| return comments.isEmpty() |
| && reviewers.isEmpty() |
| && reviewersByEmail.isEmpty() |
| && approvals.isEmpty() |
| && workInProgress == null; |
| } |
| |
| @Override |
| protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr) |
| throws IOException { |
| checkState( |
| deleteCommentRewriter == null && deleteChangeMessageRewriter == null, |
| "cannot update and rewrite ref in one BatchUpdate"); |
| |
| PatchSet.Id patchSetId = psId != null ? psId : getChange().currentPatchSetId(); |
| StringBuilder msg = new StringBuilder(); |
| if (commitSubject != null) { |
| msg.append(commitSubject); |
| } else { |
| msg.append("Update patch set ").append(patchSetId.get()); |
| } |
| msg.append("\n\n"); |
| |
| if (changeMessage != null) { |
| msg.append(changeMessage); |
| msg.append("\n\n"); |
| } |
| |
| addPatchSetFooter(msg, patchSetId); |
| |
| if (currentPatchSet) { |
| addFooter(msg, FOOTER_CURRENT, Boolean.TRUE); |
| } |
| |
| if (psDescription != null) { |
| addFooter(msg, FOOTER_PATCH_SET_DESCRIPTION, psDescription); |
| } |
| |
| if (changeId != null) { |
| addFooter(msg, FOOTER_CHANGE_ID, changeId); |
| } |
| |
| if (subject != null) { |
| addFooter(msg, FOOTER_SUBJECT, subject); |
| } |
| |
| if (branch != null) { |
| addFooter(msg, FOOTER_BRANCH, branch); |
| } |
| |
| if (status != null) { |
| addFooter(msg, FOOTER_STATUS, status.name().toLowerCase(Locale.US)); |
| if (status.equals(Change.Status.ABANDONED)) { |
| clearAttentionSet("Change was abandoned"); |
| } |
| if (status.equals(Change.Status.MERGED)) { |
| clearAttentionSet("Change was submitted"); |
| } |
| } |
| |
| if (topic != null) { |
| addFooter(msg, FOOTER_TOPIC, topic); |
| } |
| |
| if (commit != null) { |
| addFooter(msg, FOOTER_COMMIT, commit); |
| } |
| |
| Joiner comma = Joiner.on(','); |
| if (hashtags != null) { |
| addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags)); |
| } |
| |
| for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) { |
| addFooter(msg, FOOTER_CUSTOM_KEYED_VALUE, entry.getKey() + "=" + entry.getValue()); |
| } |
| |
| if (tag != null) { |
| addFooter(msg, FOOTER_TAG, tag); |
| } |
| |
| if (groups != null) { |
| addFooter(msg, FOOTER_GROUPS, comma.join(groups)); |
| } |
| |
| for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) { |
| addFooter(msg, e.getValue().getFooterKey()); |
| noteUtil.appendAccountIdIdentString(msg, e.getKey()).append('\n'); |
| } |
| |
| applyReviewerUpdatesToAttentionSet(); |
| |
| for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) { |
| addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString()); |
| } |
| |
| for (Table.Cell<String, Account.Id, Optional<PatchSetApproval>> c : approvals.cellSet()) { |
| addLabelFooter(msg, c); |
| } |
| for (PatchSetApproval patchSetApproval : copiedApprovals) { |
| addCopiedLabelFooter(msg, patchSetApproval); |
| } |
| |
| if (submissionId != null) { |
| addFooter(msg, FOOTER_SUBMISSION_ID, submissionId); |
| } |
| |
| if (submitRecords != null) { |
| for (SubmitRecord rec : submitRecords) { |
| addFooter(msg, FOOTER_SUBMITTED_WITH).append(rec.status); |
| if (rec.errorMessage != null) { |
| msg.append(' ').append(sanitizeFooter(rec.errorMessage)); |
| } |
| msg.append('\n'); |
| if (rec.ruleName != null) { |
| addFooter(msg, FOOTER_SUBMITTED_WITH).append("Rule-Name: ").append(rec.ruleName); |
| msg.append('\n'); |
| } |
| if (rec.labels != null) { |
| for (SubmitRecord.Label label : rec.labels) { |
| // Label names/values are safe to append without sanitizing. |
| addFooter(msg, FOOTER_SUBMITTED_WITH) |
| .append(label.status) |
| .append(": ") |
| .append(label.label); |
| if (label.appliedBy != null) { |
| msg.append(": "); |
| noteUtil.appendAccountIdIdentString(msg, label.appliedBy); |
| } |
| msg.append('\n'); |
| } |
| } |
| } |
| } |
| |
| if (!Objects.equals(accountId, realAccountId)) { |
| addFooter(msg, FOOTER_REAL_USER); |
| noteUtil.appendAccountIdIdentString(msg, realAccountId).append('\n'); |
| } |
| |
| if (isPrivate != null) { |
| addFooter(msg, FOOTER_PRIVATE, isPrivate); |
| } |
| |
| if (workInProgress != null) { |
| addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress); |
| if (workInProgress) { |
| clearAttentionSet("Change was marked work in progress"); |
| } else { |
| addAllReviewersToAttentionSet(); |
| } |
| } |
| |
| if (revertOf != null) { |
| addFooter(msg, FOOTER_REVERT_OF, revertOf); |
| } |
| |
| if (cherryPickOf != null) { |
| if (cherryPickOf.isPresent()) { |
| addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf.get()); |
| } else { |
| // Update cherryPickOf with an empty value. |
| addFooter(msg, FOOTER_CHERRY_PICK_OF).append('\n'); |
| } |
| } |
| |
| boolean hasAttentionSeUpdates = updateAttentionSet(msg); |
| if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) { |
| return NO_OP_UPDATE; |
| } |
| |
| CommitBuilder cb = new CommitBuilder(); |
| cb.setMessage(msg.toString()); |
| try { |
| ObjectId treeId = storeRevisionNotes(rw, ins, curr); |
| if (treeId != null) { |
| cb.setTreeId(treeId); |
| } |
| } catch (ConfigInvalidException e) { |
| throw new StorageException(e); |
| } |
| return cb; |
| } |
| |
| private void addLabelFooter( |
| StringBuilder msg, Cell<String, Account.Id, Optional<PatchSetApproval>> c) { |
| addFooter(msg, FOOTER_LABEL); |
| String label = c.getRowKey(); |
| Account.Id reviewerId = c.getColumnKey(); |
| // Label names/values are safe to append without sanitizing. |
| boolean isRemoval = !c.getValue().isPresent(); |
| if (isRemoval) { |
| msg.append('-').append(label); |
| // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not |
| // require a UUID. |
| } else { |
| short value = c.getValue().get().value(); |
| msg.append(LabelVote.create(label, value).formatWithEquals()); |
| msg.append(", "); |
| msg.append(c.getValue().get().uuid().get()); |
| } |
| if (!reviewerId.equals(getAccountId())) { |
| noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId); |
| } |
| msg.append('\n'); |
| } |
| |
| private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) { |
| if (patchSetApproval.value() == 0) { |
| addFooter(msg, FOOTER_COPIED_LABEL); |
| |
| // Mark the copied approval as deleted. |
| msg.append('-').append(patchSetApproval.label()); |
| |
| noteUtil.appendAccountIdIdentString(msg.append(' '), patchSetApproval.accountId()); |
| |
| // In the non-copied labels, we don't need to pass the real account id since it's already |
| // in FOOTER_REAL_USER. Here, we want to retain the original real account id. |
| if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) { |
| noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId()); |
| } |
| |
| msg.append('\n'); |
| return; |
| } |
| addFooter(msg, FOOTER_COPIED_LABEL); |
| // Label names/values are safe to append without sanitizing. |
| msg.append( |
| LabelVote.create(patchSetApproval.label(), patchSetApproval.value()).formatWithEquals()); |
| // Might be copied from the vote that was generated before UUID was introduced. |
| if (patchSetApproval.uuid().isPresent()) { |
| msg.append(", "); |
| msg.append(patchSetApproval.uuid().get()); |
| } |
| Account.Id id = patchSetApproval.accountId(); |
| noteUtil.appendAccountIdIdentString(msg.append(' '), id); |
| |
| // In the non-copied labels, we don't need to pass the real account id since it's already |
| // in FOOTER_REAL_USER. Here, we want to retain the original real account id. |
| if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) { |
| noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId()); |
| } |
| |
| // In the non-copied labels, we don't need to pass the tag since it's already in |
| // FOOTER_TAG, but in this chase we want to retain the original tag, and not the current tag. |
| if (patchSetApproval.tag().isPresent()) { |
| msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\""); |
| } |
| |
| msg.append('\n'); |
| } |
| |
| private void clearAttentionSet(String reason) { |
| if (getNotes().getAttentionSet() == null) { |
| return; |
| } |
| AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream() |
| .map( |
| a -> |
| AttentionSetUpdate.createForWrite( |
| a.account(), AttentionSetUpdate.Operation.REMOVE, reason)) |
| .forEach(this::addToPlannedAttentionSetUpdates); |
| } |
| |
| private void applyReviewerUpdatesToAttentionSet() { |
| if ((workInProgress != null && workInProgress == true) |
| || getNotes().getChange().isWorkInProgress() |
| || status == Change.Status.MERGED) { |
| // Attention set shouldn't change here for changes that are work in progress or are about to |
| // be submitted or when the caller is a robot. |
| return; |
| } |
| |
| Set<AttentionSetUpdate> updates = new HashSet<>(); |
| Set<Account.Id> currentReviewers = |
| getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER); |
| for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) { |
| Account.Id reviewerId = reviewer.getKey(); |
| |
| ReviewerStateInternal reviewerState = reviewer.getValue(); |
| // Only add new reviewers to the attention set. Also, don't add the owner because the owner |
| // can only be a "dummy" reviewer for legacy reasons. |
| if (reviewerState.equals(ReviewerStateInternal.REVIEWER) |
| && !currentReviewers.contains(reviewerId) |
| && !reviewerId.equals(getChange().getOwner())) { |
| updates.add( |
| AttentionSetUpdate.createForWrite( |
| reviewerId, AttentionSetUpdate.Operation.ADD, "Reviewer was added")); |
| } |
| boolean reviewerRemoved = |
| !reviewerState.equals(ReviewerStateInternal.REVIEWER) |
| && currentReviewers.contains(reviewerId); |
| boolean ccRemoved = reviewerState.equals(ReviewerStateInternal.REMOVED); |
| if (reviewerRemoved || ccRemoved) { |
| updates.add( |
| AttentionSetUpdate.createForWrite( |
| reviewerId, AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed")); |
| } |
| } |
| addToPlannedAttentionSetUpdates(updates); |
| } |
| |
| private void addAllReviewersToAttentionSet() { |
| getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER).stream() |
| .map( |
| r -> |
| AttentionSetUpdate.createForWrite( |
| r, AttentionSetUpdate.Operation.ADD, "Change was marked ready for review")) |
| .forEach(this::addToPlannedAttentionSetUpdates); |
| } |
| |
| /** |
| * Any updates to the attention set must be done in {@link #addToPlannedAttentionSetUpdates}. This |
| * method is called after all the updates are finished to do the updates once and for real. |
| * |
| * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to |
| * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is |
| * amended as well if needed. |
| * |
| * @return True if one or more attention set updates are appended to the {@code msg}, and false |
| * otherwise. |
| */ |
| private boolean updateAttentionSet(StringBuilder msg) { |
| if (plannedAttentionSetUpdates == null) { |
| plannedAttentionSetUpdates = new HashMap<>(); |
| } |
| Set<Account.Id> currentUsersInAttentionSet = |
| AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream() |
| .map(AttentionSetUpdate::account) |
| .collect(Collectors.toSet()); |
| |
| // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the |
| // deleted reviewers/ccs. |
| Set<Account.Id> currentReviewers = |
| Stream.concat( |
| getNotes().getReviewers().all().stream(), |
| reviewers.entrySet().stream() |
| .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED) |
| .map(r -> r.getKey())) |
| .collect(Collectors.toSet()); |
| currentReviewers.removeAll( |
| reviewers.entrySet().stream() |
| .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED) |
| .map(r -> r.getKey()) |
| .collect(ImmutableSet.toImmutableSet())); |
| |
| removeInactiveUsersFromAttentionSet(currentReviewers); |
| |
| boolean hasUpdates = false; |
| |
| for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) { |
| if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD |
| && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) { |
| // Skip users that are already in the attention set: no need to re-add them. |
| continue; |
| } |
| |
| if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.REMOVE |
| && !currentUsersInAttentionSet.contains(attentionSetUpdate.account())) { |
| // Skip users that are not in the attention set: no need to remove them. |
| continue; |
| } |
| |
| if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD |
| && serviceUserClassifier.isServiceUser(attentionSetUpdate.account())) { |
| // Skip adding robots to the attention set. |
| continue; |
| } |
| |
| if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD |
| && approvals.rowKeySet().contains(LabelId.legacySubmit().get())) { |
| // On submit, we sometimes can add the person who submitted the change as a reviewer, and in |
| // turn it will add that person to the attention set. |
| // This ensures we don't add users to the attention set on submit. |
| continue; |
| } |
| |
| // Don't add accounts that are not active in the change to the attention set. |
| if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD |
| && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) { |
| continue; |
| } |
| |
| addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate)); |
| attentionSetUpdatesBuilder.add(attentionSetUpdate); |
| hasUpdates = true; |
| } |
| return hasUpdates; |
| } |
| |
| private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) { |
| Set<Account.Id> inActiveUsersInTheAttentionSet = |
| // get the current attention set. |
| getNotes().getAttentionSet().stream() |
| .filter(a -> a.operation().equals(Operation.ADD)) |
| .map(a -> a.account()) |
| // remove users that are currently being removed from the attention set. |
| .filter( |
| a -> |
| plannedAttentionSetUpdates.getOrDefault(a, /* defaultValue= */ null) == null |
| || plannedAttentionSetUpdates.get(a).operation().equals(Operation.REMOVE)) |
| // remove users that are still active on the change. |
| .filter(a -> !isActiveOnChange(currentReviewers, a)) |
| .collect(ImmutableSet.toImmutableSet()); |
| |
| // We override the flag, as we never want such users in the attention set. |
| ignoreFurtherAttentionSetUpdates = false; |
| |
| addToPlannedAttentionSetUpdates( |
| inActiveUsersInTheAttentionSet.stream() |
| .map( |
| a -> |
| AttentionSetUpdate.createForWrite( |
| a, |
| Operation.REMOVE, |
| /* reason= */ "Only change owner, uploader, reviewers, and cc can " |
| + "be in the attention set")) |
| .collect(ImmutableSet.toImmutableSet())); |
| |
| ignoreFurtherAttentionSetUpdates = true; |
| } |
| |
| /** |
| * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}. |
| * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change. |
| */ |
| private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) { |
| return currentReviewers.contains(accountId) |
| || getChange().getOwner().equals(accountId) |
| || getNotes().getCurrentPatchSet().uploader().equals(accountId); |
| } |
| |
| /** |
| * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention |
| * set, etc). |
| */ |
| public void ignoreFurtherAttentionSetUpdates() { |
| ignoreFurtherAttentionSetUpdates = true; |
| } |
| |
| private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) { |
| addFooter(sb, FOOTER_PATCH_SET).append(ps.get()); |
| if (psState != null) { |
| sb.append(" (").append(psState.name().toLowerCase(Locale.US)).append(')'); |
| } |
| sb.append('\n'); |
| } |
| |
| @Override |
| protected Project.NameKey getProjectName() { |
| return getChange().getProject(); |
| } |
| |
| @Override |
| public boolean isEmpty() { |
| return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null; |
| } |
| |
| private boolean isEmptyWithoutAttentionSet() { |
| return commitSubject == null |
| && approvals.isEmpty() |
| && copiedApprovals.isEmpty() |
| && changeMessage == null |
| && comments.isEmpty() |
| && reviewers.isEmpty() |
| && reviewersByEmail.isEmpty() |
| && changeId == null |
| && branch == null |
| && status == null |
| && submissionId == null |
| && submitRecords == null |
| && hashtags == null |
| && customKeyedValues.isEmpty() |
| && topic == null |
| && commit == null |
| && psState == null |
| && groups == null |
| && tag == null |
| && psDescription == null |
| && !currentPatchSet |
| && isPrivate == null |
| && workInProgress == null |
| && revertOf == null |
| && cherryPickOf == null; |
| } |
| |
| ChangeDraftUpdate getDraftUpdate() { |
| return draftUpdate; |
| } |
| |
| RobotCommentUpdate getRobotCommentUpdate() { |
| return robotCommentUpdate; |
| } |
| |
| DeleteCommentRewriter getDeleteCommentRewriter() { |
| return deleteCommentRewriter; |
| } |
| |
| DeleteChangeMessageRewriter getDeleteChangeMessageRewriter() { |
| return deleteChangeMessageRewriter; |
| } |
| |
| public void setAllowWriteToNewRef(boolean allow) { |
| isAllowWriteToNewtRef = allow; |
| } |
| |
| @Override |
| public boolean allowWriteToNewRef() { |
| return isAllowWriteToNewtRef; |
| } |
| |
| public void setPrivate(boolean isPrivate) { |
| this.isPrivate = isPrivate; |
| } |
| |
| public void setWorkInProgress(boolean workInProgress) { |
| this.workInProgress = workInProgress; |
| } |
| |
| private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) { |
| return sb.append(footer.getName()).append(": "); |
| } |
| |
| private static void addFooter(StringBuilder sb, FooterKey footer, Object... values) { |
| addFooter(sb, footer); |
| for (Object value : values) { |
| sb.append(sanitizeFooter(Objects.toString(value))); |
| } |
| sb.append('\n'); |
| } |
| |
| private static boolean isIllegalTopic(String topic) { |
| return (topic != null && topic.contains("\"")); |
| } |
| } |