blob: c0cd173428254aa71a9ac5cf96672fae207927b2 [file] [log] [blame]
// 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.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_PRIVATE;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
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.FOOTER_WORK_IN_PROGRESS;
import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
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.Table;
import com.google.common.collect.TreeBasedTable;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.mail.Address;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.logging.RequestId;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.util.LabelVote;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
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.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(ChangeNotes notes, CurrentUser user, Date when);
@VisibleForTesting
ChangeUpdate create(
ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
}
private final NoteDbUpdateManager.Factory updateManagerFactory;
private final ChangeDraftUpdate.Factory draftUpdateFactory;
private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
private final Table<String, Account.Id, Optional<Short>> approvals;
private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
private final Map<Address, ReviewerStateInternal> reviewersByEmail = 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 Boolean isPrivate;
private Boolean workInProgress;
private Integer revertOf;
private ChangeDraftUpdate draftUpdate;
private RobotCommentUpdate robotCommentUpdate;
private DeleteCommentRewriter deleteCommentRewriter;
private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
@AssistedInject
private ChangeUpdate(
@GerritPersonIdent PersonIdent serverIdent,
NoteDbUpdateManager.Factory updateManagerFactory,
ChangeDraftUpdate.Factory draftUpdateFactory,
RobotCommentUpdate.Factory robotCommentUpdateFactory,
DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
ProjectCache projectCache,
@Assisted ChangeNotes notes,
@Assisted CurrentUser user,
@Assisted Date when,
ChangeNoteUtil noteUtil) {
this(
serverIdent,
updateManagerFactory,
draftUpdateFactory,
robotCommentUpdateFactory,
deleteCommentRewriterFactory,
notes,
user,
when,
projectCache.get(notes.getProjectName()).getLabelTypes().nameComparator(),
noteUtil);
}
private static Table<String, Account.Id, Optional<Short>> approvals(
Comparator<String> nameComparator) {
return TreeBasedTable.create(nameComparator, naturalOrder());
}
@AssistedInject
private ChangeUpdate(
@GerritPersonIdent PersonIdent serverIdent,
NoteDbUpdateManager.Factory updateManagerFactory,
ChangeDraftUpdate.Factory draftUpdateFactory,
RobotCommentUpdate.Factory robotCommentUpdateFactory,
DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
@Assisted ChangeNotes notes,
@Assisted CurrentUser user,
@Assisted Date 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.approvals = approvals(labelNameComparator);
}
public ObjectId commit() throws IOException {
try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
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(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");
}
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(Comment.Status status, Comment c) {
verifyComment(c);
createDraftUpdateIfNull();
if (status == Comment.Status.DRAFT) {
draftUpdate.putComment(c);
} else {
comments.add(c);
draftUpdate.markCommentPublished(c);
}
}
public void putRobotComment(RobotComment c) {
verifyComment(c);
createRobotCommentUpdateIfNull();
robotCommentUpdate.putComment(c);
}
public void deleteComment(Comment c) {
verifyComment(c);
createDraftUpdateIfNull().deleteComment(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;
}
@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 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;
}
/** @return the tree id for the updated tree */
private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
throws ConfigInvalidException, 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(c.getCommitId()).putComment(c);
}
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, Comment.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<>();
for (ChangeRevisionNote rn : existingNotes.values()) {
for (Comment c : rn.getEntities()) {
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.getCommitId(), c.key);
}
}
}
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() {
// Allow abandoning or submitting a change even if it would exceed the max update count.
return status != null && status.isClosed();
}
@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");
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 (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
}
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');
}
}
// TODO(maximeg) We might want to list plugins that validated this submission.
}
}
if (!Objects.equals(accountId, realAccountId)) {
addFooter(msg, FOOTER_REAL_USER);
addIdent(msg, realAccountId).append('\n');
}
if (isPrivate != null) {
addFooter(msg, FOOTER_PRIVATE, isPrivate);
}
if (workInProgress != null) {
addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
}
if (revertOf != null) {
addFooter(msg, FOOTER_REVERT_OF, revertOf);
}
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 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()
&& reviewersByEmail.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
&& isPrivate == null
&& workInProgress == null
&& revertOf == null;
}
ChangeDraftUpdate getDraftUpdate() {
return draftUpdate;
}
RobotCommentUpdate getRobotCommentUpdate() {
return robotCommentUpdate;
}
public DeleteCommentRewriter getDeleteCommentRewriter() {
return deleteCommentRewriter;
}
public 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 StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
PersonIdent ident = newIdent(accountId, when);
PersonIdent.appendSanitized(sb, ident.getName());
sb.append(" <");
PersonIdent.appendSanitized(sb, ident.getEmailAddress());
sb.append('>');
return sb;
}
}