| // 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.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter; |
| import static java.util.Comparator.comparing; |
| 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.Table; |
| import com.google.common.collect.TreeBasedTable; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.common.data.SubmitRecord; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.Comment; |
| import com.google.gerrit.reviewdb.client.PatchLineComment; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.reviewdb.client.RevId; |
| import com.google.gerrit.reviewdb.client.RobotComment; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.config.AnonymousCowardName; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.project.ChangeControl; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.util.LabelVote; |
| import com.google.gerrit.server.util.RequestId; |
| import com.google.gwtorm.client.IntKey; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.inject.assistedinject.Assisted; |
| import com.google.inject.assistedinject.AssistedInject; |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.ArrayList; |
| import java.util.Comparator; |
| import java.util.Date; |
| import java.util.HashSet; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.util.Set; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| import org.eclipse.jgit.lib.Config; |
| 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. |
| */ |
| public class ChangeUpdate extends AbstractChangeUpdate { |
| public interface Factory { |
| ChangeUpdate create(ChangeControl ctl); |
| |
| ChangeUpdate create(ChangeControl ctl, Date when); |
| |
| ChangeUpdate create( |
| Change change, |
| @Assisted("effective") @Nullable Account.Id accountId, |
| @Assisted("real") @Nullable Account.Id realAccountId, |
| PersonIdent authorIdent, |
| Date when, |
| Comparator<String> labelNameComparator); |
| |
| @VisibleForTesting |
| ChangeUpdate create(ChangeControl ctl, Date when, Comparator<String> labelNameComparator); |
| } |
| |
| private final AccountCache accountCache; |
| private final ChangeDraftUpdate.Factory draftUpdateFactory; |
| private final RobotCommentUpdate.Factory robotCommentUpdateFactory; |
| private final NoteDbUpdateManager.Factory updateManagerFactory; |
| |
| private final Table<String, Account.Id, Optional<Short>> approvals; |
| private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>(); |
| private final List<Comment> 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 Optional<Account.Id> assignee; |
| private Set<String> hashtags; |
| private String changeMessage; |
| private String tag; |
| private PatchSetState psState; |
| private Iterable<String> groups; |
| private String pushCert; |
| private boolean isAllowWriteToNewtRef; |
| private String psDescription; |
| private boolean currentPatchSet; |
| private Timestamp readOnlyUntil; |
| |
| private ChangeDraftUpdate draftUpdate; |
| private RobotCommentUpdate robotCommentUpdate; |
| |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritServerConfig Config cfg, |
| @GerritPersonIdent PersonIdent serverIdent, |
| @AnonymousCowardName String anonymousCowardName, |
| NotesMigration migration, |
| AccountCache accountCache, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.Factory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| ProjectCache projectCache, |
| @Assisted ChangeControl ctl, |
| ChangeNoteUtil noteUtil) { |
| this( |
| cfg, |
| serverIdent, |
| anonymousCowardName, |
| migration, |
| accountCache, |
| updateManagerFactory, |
| draftUpdateFactory, |
| robotCommentUpdateFactory, |
| projectCache, |
| ctl, |
| serverIdent.getWhen(), |
| noteUtil); |
| } |
| |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritServerConfig Config cfg, |
| @GerritPersonIdent PersonIdent serverIdent, |
| @AnonymousCowardName String anonymousCowardName, |
| NotesMigration migration, |
| AccountCache accountCache, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.Factory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| ProjectCache projectCache, |
| @Assisted ChangeControl ctl, |
| @Assisted Date when, |
| ChangeNoteUtil noteUtil) { |
| this( |
| cfg, |
| serverIdent, |
| anonymousCowardName, |
| migration, |
| accountCache, |
| updateManagerFactory, |
| draftUpdateFactory, |
| robotCommentUpdateFactory, |
| ctl, |
| when, |
| projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(), |
| noteUtil); |
| } |
| |
| private static Project.NameKey getProjectName(ChangeControl ctl) { |
| return ctl.getProject().getNameKey(); |
| } |
| |
| private static Table<String, Account.Id, Optional<Short>> approvals( |
| Comparator<String> nameComparator) { |
| return TreeBasedTable.create(nameComparator, comparing(IntKey::get)); |
| } |
| |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritServerConfig Config cfg, |
| @GerritPersonIdent PersonIdent serverIdent, |
| @AnonymousCowardName String anonymousCowardName, |
| NotesMigration migration, |
| AccountCache accountCache, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.Factory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| @Assisted ChangeControl ctl, |
| @Assisted Date when, |
| @Assisted Comparator<String> labelNameComparator, |
| ChangeNoteUtil noteUtil) { |
| super(cfg, migration, ctl, serverIdent, anonymousCowardName, noteUtil, when); |
| this.accountCache = accountCache; |
| this.draftUpdateFactory = draftUpdateFactory; |
| this.robotCommentUpdateFactory = robotCommentUpdateFactory; |
| this.updateManagerFactory = updateManagerFactory; |
| this.approvals = approvals(labelNameComparator); |
| } |
| |
| @AssistedInject |
| private ChangeUpdate( |
| @GerritServerConfig Config cfg, |
| @GerritPersonIdent PersonIdent serverIdent, |
| @AnonymousCowardName String anonymousCowardName, |
| NotesMigration migration, |
| AccountCache accountCache, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeDraftUpdate.Factory draftUpdateFactory, |
| RobotCommentUpdate.Factory robotCommentUpdateFactory, |
| ChangeNoteUtil noteUtil, |
| @Assisted Change change, |
| @Assisted("effective") @Nullable Account.Id accountId, |
| @Assisted("real") @Nullable Account.Id realAccountId, |
| @Assisted PersonIdent authorIdent, |
| @Assisted Date when, |
| @Assisted Comparator<String> labelNameComparator) { |
| super( |
| cfg, |
| migration, |
| noteUtil, |
| serverIdent, |
| anonymousCowardName, |
| null, |
| change, |
| accountId, |
| realAccountId, |
| authorIdent, |
| when); |
| this.accountCache = accountCache; |
| this.draftUpdateFactory = draftUpdateFactory; |
| this.robotCommentUpdateFactory = robotCommentUpdateFactory; |
| this.updateManagerFactory = updateManagerFactory; |
| this.approvals = approvals(labelNameComparator); |
| } |
| |
| public ObjectId commit() throws IOException, OrmException { |
| try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) { |
| updateManager.add(this); |
| updateManager.stageAndApplyDelta(getChange()); |
| 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(Iterable<SubmitRecord>)"); |
| this.status = status; |
| } |
| |
| public void fixStatus(Change.Status status) { |
| this.status = status; |
| } |
| |
| public void putApproval(String label, short value) { |
| putApprovalFor(getAccountId(), label, value); |
| } |
| |
| public void putApprovalFor(Account.Id reviewer, String label, short value) { |
| approvals.put(label, reviewer, Optional.of(value)); |
| } |
| |
| public void removeApproval(String label) { |
| removeApprovalFor(getAccountId(), label); |
| } |
| |
| public void removeApprovalFor(Account.Id reviewer, String label) { |
| approvals.put(label, reviewer, Optional.empty()); |
| } |
| |
| public void merge(RequestId submissionId, Iterable<SubmitRecord> submitRecords) { |
| this.status = Change.Status.MERGED; |
| this.submissionId = submissionId.toStringForStorage(); |
| this.submitRecords = ImmutableList.copyOf(submitRecords); |
| checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time"); |
| } |
| |
| @Deprecated // Only until we improve ChangeRebuilder to call merge(). |
| public void setSubmissionId(String submissionId) { |
| this.submissionId = submissionId; |
| } |
| |
| 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 putComment(PatchLineComment.Status status, Comment c) { |
| verifyComment(c); |
| createDraftUpdateIfNull(); |
| if (status == PatchLineComment.Status.DRAFT) { |
| draftUpdate.putComment(c); |
| } else { |
| comments.add(c); |
| // Always delete the corresponding comment from drafts. Published comments |
| // are immutable, meaning in normal operation we only hit this path when |
| // publishing a comment. It's exactly in that case that we have to delete |
| // the draft. |
| draftUpdate.deleteComment(c); |
| } |
| } |
| |
| public void putRobotComment(RobotComment c) { |
| verifyComment(c); |
| createRobotCommentUpdateIfNull(); |
| robotCommentUpdate.putComment(c); |
| } |
| |
| public void deleteComment(Comment c) { |
| verifyComment(c); |
| createDraftUpdateIfNull().deleteComment(c); |
| } |
| |
| @VisibleForTesting |
| ChangeDraftUpdate createDraftUpdateIfNull() { |
| if (draftUpdate == null) { |
| ChangeNotes notes = getNotes(); |
| if (notes != null) { |
| draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when); |
| } else { |
| draftUpdate = |
| draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when); |
| } |
| } |
| return draftUpdate; |
| } |
| |
| @VisibleForTesting |
| RobotCommentUpdate 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); |
| } |
| } |
| return robotCommentUpdate; |
| } |
| |
| public void setTopic(String topic) { |
| 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; |
| } |
| |
| /** |
| * Set the revision without depending on the commit being present in the repository; should only |
| * be used for converting old corrupt commits. |
| */ |
| public void setRevisionForMissingCommit(String id, String pushCert) { |
| commit = id; |
| this.pushCert = pushCert; |
| } |
| |
| public void setHashtags(Set<String> hashtags) { |
| this.hashtags = hashtags; |
| } |
| |
| public void setAssignee(Account.Id assignee) { |
| checkArgument(assignee != null, "use removeAssignee"); |
| this.assignee = Optional.of(assignee); |
| } |
| |
| public void removeAssignee() { |
| this.assignee = Optional.empty(); |
| } |
| |
| 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 setPatchSetState(PatchSetState psState) { |
| this.psState = psState; |
| } |
| |
| public void setCurrentPatchSet() { |
| this.currentPatchSet = true; |
| } |
| |
| public void setGroups(List<String> groups) { |
| checkNotNull(groups, "groups may not be null"); |
| this.groups = groups; |
| } |
| |
| /** @return the tree id for the updated tree */ |
| private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr) |
| throws ConfigInvalidException, OrmException, IOException { |
| if (comments.isEmpty() && pushCert == null) { |
| return null; |
| } |
| RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr); |
| |
| RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm); |
| for (Comment c : comments) { |
| c.tag = tag; |
| cache.get(new RevId(c.revId)).putComment(c); |
| } |
| if (pushCert != null) { |
| checkState(commit != null); |
| cache.get(new RevId(commit)).setPushCertificate(pushCert); |
| } |
| Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders(); |
| checkComments(rnm.revisionNotes, builders); |
| |
| for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) { |
| ObjectId data = |
| inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson())); |
| rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data); |
| } |
| |
| return rnm.noteMap.writeTree(inserter); |
| } |
| |
| private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr) |
| throws ConfigInvalidException, OrmException, IOException { |
| if (curr.equals(ObjectId.zeroId())) { |
| return RevisionNoteMap.emptyMap(); |
| } |
| if (migration.readChanges()) { |
| // If reading from changes is enabled, then 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, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.PUBLISHED); |
| } |
| |
| private void checkComments( |
| Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate) |
| throws OrmException { |
| // Prohibit various kinds of illegal operations on comments. |
| Set<Comment.Key> existing = new HashSet<>(); |
| for (ChangeRevisionNote rn : existingNotes.values()) { |
| for (Comment c : rn.getComments()) { |
| existing.add(c.key); |
| 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#getDraftComments, |
| // 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.deleteComment(c.revId, c.key); |
| } |
| } |
| } |
| |
| for (RevisionNoteBuilder b : toUpdate.values()) { |
| for (Comment c : b.put.values()) { |
| if (existing.contains(c.key)) { |
| throw new OrmException("Cannot update existing published comment: " + c); |
| } |
| } |
| } |
| } |
| |
| @Override |
| protected String getRefName() { |
| return changeMetaRef(getId()); |
| } |
| |
| @Override |
| protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr) |
| throws OrmException, IOException { |
| CommitBuilder cb = new CommitBuilder(); |
| |
| int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get(); |
| StringBuilder msg = new StringBuilder(); |
| if (commitSubject != null) { |
| msg.append(commitSubject); |
| } else { |
| msg.append("Update patch set ").append(ps); |
| } |
| msg.append("\n\n"); |
| |
| if (changeMessage != null) { |
| msg.append(changeMessage); |
| msg.append("\n\n"); |
| } |
| |
| addPatchSetFooter(msg, ps); |
| |
| 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()); |
| } |
| |
| if (topic != null) { |
| addFooter(msg, FOOTER_TOPIC, topic); |
| } |
| |
| if (commit != null) { |
| addFooter(msg, FOOTER_COMMIT, commit); |
| } |
| |
| if (assignee != null) { |
| if (assignee.isPresent()) { |
| addFooter(msg, FOOTER_ASSIGNEE); |
| addIdent(msg, assignee.get()).append('\n'); |
| } else { |
| addFooter(msg, FOOTER_ASSIGNEE).append('\n'); |
| } |
| } |
| |
| Joiner comma = Joiner.on(','); |
| if (hashtags != null) { |
| addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags)); |
| } |
| |
| 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()); |
| addIdent(msg, e.getKey()).append('\n'); |
| } |
| |
| for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) { |
| addFooter(msg, FOOTER_LABEL); |
| // Label names/values are safe to append without sanitizing. |
| if (!c.getValue().isPresent()) { |
| msg.append('-').append(c.getRowKey()); |
| } else { |
| msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals()); |
| } |
| Account.Id id = c.getColumnKey(); |
| if (!id.equals(getAccountId())) { |
| addIdent(msg.append(' '), id); |
| } |
| msg.append('\n'); |
| } |
| |
| 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.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(": "); |
| addIdent(msg, label.appliedBy); |
| } |
| msg.append('\n'); |
| } |
| } |
| } |
| } |
| |
| if (!Objects.equals(accountId, realAccountId)) { |
| addFooter(msg, FOOTER_REAL_USER); |
| addIdent(msg, realAccountId).append('\n'); |
| } |
| |
| if (readOnlyUntil != null) { |
| addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil)); |
| } |
| |
| cb.setMessage(msg.toString()); |
| try { |
| ObjectId treeId = storeRevisionNotes(rw, ins, curr); |
| if (treeId != null) { |
| cb.setTreeId(treeId); |
| } |
| } catch (ConfigInvalidException e) { |
| throw new OrmException(e); |
| } |
| return cb; |
| } |
| |
| private void addPatchSetFooter(StringBuilder sb, int ps) { |
| addFooter(sb, FOOTER_PATCH_SET).append(ps); |
| if (psState != null) { |
| sb.append(" (").append(psState.name().toLowerCase()).append(')'); |
| } |
| sb.append('\n'); |
| } |
| |
| @Override |
| protected Project.NameKey getProjectName() { |
| return getChange().getProject(); |
| } |
| |
| @Override |
| public boolean isEmpty() { |
| return commitSubject == null |
| && approvals.isEmpty() |
| && changeMessage == null |
| && comments.isEmpty() |
| && reviewers.isEmpty() |
| && changeId == null |
| && branch == null |
| && status == null |
| && submissionId == null |
| && submitRecords == null |
| && assignee == null |
| && hashtags == null |
| && topic == null |
| && commit == null |
| && psState == null |
| && groups == null |
| && tag == null |
| && psDescription == null |
| && !currentPatchSet |
| && readOnlyUntil == null; |
| } |
| |
| ChangeDraftUpdate getDraftUpdate() { |
| return draftUpdate; |
| } |
| |
| RobotCommentUpdate getRobotCommentUpdate() { |
| return robotCommentUpdate; |
| } |
| |
| public void setAllowWriteToNewRef(boolean allow) { |
| isAllowWriteToNewtRef = allow; |
| } |
| |
| @Override |
| public boolean allowWriteToNewRef() { |
| return isAllowWriteToNewtRef; |
| } |
| |
| void setReadOnlyUntil(Timestamp readOnlyUntil) { |
| this.readOnlyUntil = readOnlyUntil; |
| } |
| |
| 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 StringBuilder addIdent(StringBuilder sb, Account.Id accountId) { |
| Account account = accountCache.get(accountId).getAccount(); |
| PersonIdent ident = newIdent(account, when); |
| |
| PersonIdent.appendSanitized(sb, ident.getName()); |
| sb.append(" <"); |
| PersonIdent.appendSanitized(sb, ident.getEmailAddress()); |
| sb.append('>'); |
| return sb; |
| } |
| |
| @Override |
| protected void checkNotReadOnly() throws OrmException { |
| // Allow setting Read-only-until to 0 to release an existing lease. |
| if (readOnlyUntil != null && readOnlyUntil.getTime() == 0) { |
| return; |
| } |
| super.checkNotReadOnly(); |
| } |
| } |