blob: 4c1a73464606267fab806bd71a62231761888169 [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.Preconditions.checkArgument;
import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Multimap;
import com.google.common.primitives.Ints;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.CommentRange;
import com.google.gerrit.reviewdb.client.Patch;
import com.google.gerrit.reviewdb.client.PatchLineComment;
import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
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.GerritServerId;
import com.google.inject.Inject;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.util.GitDateFormatter;
import org.eclipse.jgit.util.GitDateFormatter.Format;
import org.eclipse.jgit.util.GitDateParser;
import org.eclipse.jgit.util.MutableInteger;
import org.eclipse.jgit.util.QuotedString;
import org.eclipse.jgit.util.RawParseUtils;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.sql.Timestamp;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public class ChangeNoteUtil {
static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
static final FooterKey FOOTER_LABEL = new FooterKey("Label");
static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
static final FooterKey FOOTER_STATUS = new FooterKey("Status");
static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
static final FooterKey FOOTER_SUBMITTED_WITH =
new FooterKey("Submitted-with");
static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
static final FooterKey FOOTER_TAG = new FooterKey("Tag");
private static final String AUTHOR = "Author";
private static final String BASE_PATCH_SET = "Base-for-patch-set";
private static final String COMMENT_RANGE = "Comment-range";
private static final String FILE = "File";
private static final String LENGTH = "Bytes";
private static final String PARENT = "Parent";
private static final String PARENT_NUMBER = "Parent-number";
private static final String PATCH_SET = "Patch-set";
private static final String REVISION = "Revision";
private static final String UUID = "UUID";
private static final String TAG = FOOTER_TAG.getName();
public static String formatTime(PersonIdent ident, Timestamp t) {
GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
// TODO(dborowitz): Use a ThreadLocal or use Joda.
PersonIdent newIdent = new PersonIdent(ident, t);
return dateFormatter.formatDate(newIdent);
}
private final AccountCache accountCache;
private final PersonIdent serverIdent;
private final String anonymousCowardName;
private final String serverId;
@Inject
public ChangeNoteUtil(AccountCache accountCache,
@GerritPersonIdent PersonIdent serverIdent,
@AnonymousCowardName String anonymousCowardName,
@GerritServerId String serverId) {
this.accountCache = accountCache;
this.serverIdent = serverIdent;
this.anonymousCowardName = anonymousCowardName;
this.serverId = serverId;
}
@VisibleForTesting
public PersonIdent newIdent(Account author, Date when,
PersonIdent serverIdent, String anonymousCowardName) {
return new PersonIdent(
author.getName(anonymousCowardName),
author.getId().get() + "@" + serverId,
when, serverIdent.getTimeZone());
}
public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
throws ConfigInvalidException {
String email = ident.getEmailAddress();
int at = email.indexOf('@');
if (at >= 0) {
String host = email.substring(at + 1, email.length());
if (host.equals(serverId)) {
Integer id = Ints.tryParse(email.substring(0, at));
if (id != null) {
return new Account.Id(id);
}
}
}
throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
serverId, email);
}
private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
int m = RawParseUtils.match(note, p.value, expected);
return m == p.value + expected.length;
}
public List<PatchLineComment> parseNote(byte[] note, MutableInteger p,
Change.Id changeId, Status status) throws ConfigInvalidException {
if (p.value >= note.length) {
return ImmutableList.of();
}
Set<PatchLineComment.Key> seen = new HashSet<>();
List<PatchLineComment> result = new ArrayList<>();
int sizeOfNote = note.length;
byte[] psb = PATCH_SET.getBytes(UTF_8);
byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);
RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
String fileName = null;
PatchSet.Id psId = null;
boolean isForBase = false;
Integer parentNumber = null;
while (p.value < sizeOfNote) {
boolean matchPs = match(note, p, psb);
boolean matchBase = match(note, p, bpsb);
if (matchPs) {
fileName = null;
psId = parsePsId(note, p, changeId, PATCH_SET);
isForBase = false;
} else if (matchBase) {
fileName = null;
psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
isForBase = true;
if (match(note, p, bpn)) {
parentNumber = parseParentNumber(note, p, changeId);
}
} else if (psId == null) {
throw parseException(changeId, "missing %s or %s header",
PATCH_SET, BASE_PATCH_SET);
}
PatchLineComment c = parseComment(
note, p, fileName, psId, revId, isForBase, parentNumber, status);
fileName = c.getKey().getParentKey().getFileName();
if (!seen.add(c.getKey())) {
throw parseException(
changeId, "multiple comments for %s in note", c.getKey());
}
result.add(c);
}
return result;
}
private PatchLineComment parseComment(byte[] note, MutableInteger curr,
String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
Integer parentNumber, Status status) throws ConfigInvalidException {
Change.Id changeId = psId.getParentKey();
// Check if there is a new file.
boolean newFile =
(RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
if (newFile) {
// If so, parse the new file name.
currentFileName = parseFilename(note, curr, changeId);
} else if (currentFileName == null) {
throw parseException(changeId, "could not parse %s", FILE);
}
CommentRange range = parseCommentRange(note, curr);
if (range == null) {
throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
}
Timestamp commentTime = parseTimestamp(note, curr, changeId);
Account.Id aId = parseAuthor(note, curr, changeId);
boolean hasParent =
(RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
String parentUUID = null;
if (hasParent) {
parentUUID = parseStringField(note, curr, changeId, PARENT);
}
String uuid = parseStringField(note, curr, changeId, UUID);
boolean hasTag =
(RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
String tag = null;
if (hasTag) {
tag = parseStringField(note, curr, changeId, TAG);
}
int commentLength = parseCommentLength(note, curr, changeId);
String message = RawParseUtils.decode(
UTF_8, note, curr.value, curr.value + commentLength);
checkResult(message, "message contents", changeId);
PatchLineComment plc = new PatchLineComment(
new PatchLineComment.Key(new Patch.Key(psId, currentFileName), uuid),
range.getEndLine(), aId, parentUUID, commentTime);
plc.setMessage(message);
plc.setTag(tag);
if (isForBase) {
plc.setSide((short) (parentNumber == null ? 0 : -parentNumber));
} else {
plc.setSide((short) 1);
}
if (range.getStartCharacter() != -1) {
plc.setRange(range);
}
plc.setRevId(revId);
plc.setStatus(status);
curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
curr.value = RawParseUtils.nextLF(note, curr.value);
return plc;
}
private static String parseStringField(byte[] note, MutableInteger curr,
Change.Id changeId, String fieldName) throws ConfigInvalidException {
int endOfLine = RawParseUtils.nextLF(note, curr.value);
checkHeaderLineFormat(note, curr, fieldName, changeId);
int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
curr.value = endOfLine;
return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
}
/**
* @return a comment range. If the comment range line in the note only has
* one number, we return a CommentRange with that one number as the end
* line and the other fields as -1. If the comment range line in the note
* contains a whole comment range, then we return a CommentRange with all
* fields set. If the line is not correctly formatted, return null.
*/
private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
CommentRange range = new CommentRange(-1, -1, -1, -1);
int last = ptr.value;
int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (ptr.value == last) {
return null;
} else if (note[ptr.value] == '\n') {
range.setEndLine(startLine);
ptr.value += 1;
return range;
} else if (note[ptr.value] == ':') {
range.setStartLine(startLine);
ptr.value += 1;
} else {
return null;
}
last = ptr.value;
int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (ptr.value == last) {
return null;
} else if (note[ptr.value] == '-') {
range.setStartCharacter(startChar);
ptr.value += 1;
} else {
return null;
}
last = ptr.value;
int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (ptr.value == last) {
return null;
} else if (note[ptr.value] == ':') {
range.setEndLine(endLine);
ptr.value += 1;
} else {
return null;
}
last = ptr.value;
int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (ptr.value == last) {
return null;
} else if (note[ptr.value] == '\n') {
range.setEndCharacter(endChar);
ptr.value += 1;
} else {
return null;
}
return range;
}
private static PatchSet.Id parsePsId(byte[] note, MutableInteger curr,
Change.Id changeId, String fieldName) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, fieldName, changeId);
int startOfPsId =
RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
MutableInteger i = new MutableInteger();
int patchSetId =
RawParseUtils.parseBase10(note, startOfPsId, i);
int endOfLine = RawParseUtils.nextLF(note, curr.value);
if (i.value != endOfLine - 1) {
throw parseException(changeId, "could not parse %s", fieldName);
}
checkResult(patchSetId, "patchset id", changeId);
curr.value = endOfLine;
return new PatchSet.Id(changeId, patchSetId);
}
private static Integer parseParentNumber(byte[] note, MutableInteger curr,
Change.Id changeId) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
MutableInteger i = new MutableInteger();
int parentNumber = RawParseUtils.parseBase10(note, start, i);
int endOfLine = RawParseUtils.nextLF(note, curr.value);
if (i.value != endOfLine - 1) {
throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
}
checkResult(parentNumber, "parent number", changeId);
curr.value = endOfLine;
return Integer.valueOf(parentNumber);
}
private static String parseFilename(byte[] note, MutableInteger curr,
Change.Id changeId) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, FILE, changeId);
int startOfFileName =
RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
int endOfLine = RawParseUtils.nextLF(note, curr.value);
curr.value = endOfLine;
curr.value = RawParseUtils.nextLF(note, curr.value);
return QuotedString.GIT_PATH.dequote(
RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
}
private static Timestamp parseTimestamp(byte[] note, MutableInteger curr,
Change.Id changeId) throws ConfigInvalidException {
int endOfLine = RawParseUtils.nextLF(note, curr.value);
Timestamp commentTime;
String dateString =
RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
try {
commentTime = new Timestamp(
GitDateParser.parse(dateString, null, Locale.US).getTime());
} catch (ParseException e) {
throw new ConfigInvalidException("could not parse comment timestamp", e);
}
curr.value = endOfLine;
return checkResult(commentTime, "comment timestamp", changeId);
}
private Account.Id parseAuthor(byte[] note, MutableInteger curr,
Change.Id changeId) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, AUTHOR, changeId);
int startOfAccountId =
RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
PersonIdent ident =
RawParseUtils.parsePersonIdent(note, startOfAccountId);
Account.Id aId = parseIdent(ident, changeId);
curr.value = RawParseUtils.nextLF(note, curr.value);
return checkResult(aId, "comment author", changeId);
}
private static int parseCommentLength(byte[] note, MutableInteger curr,
Change.Id changeId) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, LENGTH, changeId);
int startOfLength =
RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
MutableInteger i = new MutableInteger();
i.value = startOfLength;
int commentLength =
RawParseUtils.parseBase10(note, startOfLength, i);
if (i.value == startOfLength) {
throw parseException(changeId, "could not parse %s", LENGTH);
}
int endOfLine = RawParseUtils.nextLF(note, curr.value);
if (i.value != endOfLine - 1) {
throw parseException(changeId, "could not parse %s", LENGTH);
}
curr.value = endOfLine;
return checkResult(commentLength, "comment length", changeId);
}
private static <T> T checkResult(T o, String fieldName,
Change.Id changeId) throws ConfigInvalidException {
if (o == null) {
throw parseException(changeId, "could not parse %s", fieldName);
}
return o;
}
private static int checkResult(int i, String fieldName, Change.Id changeId)
throws ConfigInvalidException {
if (i <= 0) {
throw parseException(changeId, "could not parse %s", fieldName);
}
return i;
}
private void appendHeaderField(PrintWriter writer,
String field, String value) {
writer.print(field);
writer.print(": ");
writer.print(value);
writer.print('\n');
}
private static void checkHeaderLineFormat(byte[] note, MutableInteger curr,
String fieldName, Change.Id changeId) throws ConfigInvalidException {
boolean correct =
RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
int p = curr.value + fieldName.length();
correct &= (p < note.length && note[p] == ':');
p++;
correct &= (p < note.length && note[p] == ' ');
if (!correct) {
throw parseException(changeId, "could not parse %s", fieldName);
}
}
/**
* Build a note that contains the metadata for and the contents of all of the
* comments in the given comments.
*
* @param comments Comments to be written to the output stream, keyed by patch
* set ID; multiple patch sets are allowed since base revisions may be
* shared across patch sets. All of the comments must share the same
* RevId, and all the comments for a given patch set must have the same
* side.
* @param out output stream to write to.
*/
void buildNote(Multimap<PatchSet.Id, PatchLineComment> comments,
OutputStream out) {
if (comments.isEmpty()) {
return;
}
List<PatchSet.Id> psIds =
ReviewDbUtil.intKeyOrdering().sortedCopy(comments.keySet());
OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
try (PrintWriter writer = new PrintWriter(streamWriter)) {
RevId revId = comments.values().iterator().next().getRevId();
appendHeaderField(writer, REVISION, revId.get());
for (PatchSet.Id psId : psIds) {
List<PatchLineComment> psComments =
PLC_ORDER.sortedCopy(comments.get(psId));
PatchLineComment first = psComments.get(0);
short side = first.getSide();
appendHeaderField(writer, side <= 0
? BASE_PATCH_SET
: PATCH_SET,
Integer.toString(psId.get()));
if (side < 0) {
appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
}
String currentFilename = null;
for (PatchLineComment c : psComments) {
checkArgument(revId.equals(c.getRevId()),
"All comments being added must have all the same RevId. The "
+ "comment below does not have the same RevId as the others "
+ "(%s).\n%s", revId, c);
checkArgument(side == c.getSide(),
"All comments being added must all have the same side. The "
+ "comment below does not have the same side as the others "
+ "(%s).\n%s", side, c);
String commentFilename = QuotedString.GIT_PATH.quote(
c.getKey().getParentKey().getFileName());
if (!commentFilename.equals(currentFilename)) {
currentFilename = commentFilename;
writer.print("File: ");
writer.print(commentFilename);
writer.print("\n\n");
}
appendOneComment(writer, c);
}
}
}
}
private void appendOneComment(PrintWriter writer, PatchLineComment c) {
// The CommentRange field for a comment is allowed to be null. If it is
// null, then in the first line, we simply use the line number field for a
// comment instead. If it isn't null, we write the comment range itself.
CommentRange range = c.getRange();
if (range != null) {
writer.print(range.getStartLine());
writer.print(':');
writer.print(range.getStartCharacter());
writer.print('-');
writer.print(range.getEndLine());
writer.print(':');
writer.print(range.getEndCharacter());
} else {
writer.print(c.getLine());
}
writer.print("\n");
writer.print(formatTime(serverIdent, c.getWrittenOn()));
writer.print("\n");
PersonIdent ident = newIdent(
accountCache.get(c.getAuthor()).getAccount(),
c.getWrittenOn(), serverIdent, anonymousCowardName);
StringBuilder name = new StringBuilder();
PersonIdent.appendSanitized(name, ident.getName());
name.append(" <");
PersonIdent.appendSanitized(name, ident.getEmailAddress());
name.append('>');
appendHeaderField(writer, AUTHOR, name.toString());
String parent = c.getParentUuid();
if (parent != null) {
appendHeaderField(writer, PARENT, parent);
}
appendHeaderField(writer, UUID, c.getKey().get());
if (c.getTag() != null) {
appendHeaderField(writer, TAG, c.getTag());
}
byte[] messageBytes = c.getMessage().getBytes(UTF_8);
appendHeaderField(writer, LENGTH,
Integer.toString(messageBytes.length));
writer.print(c.getMessage());
writer.print("\n\n");
}
}