blob: f3e03c0110f4e1b0ee99c036c64d706eac2c6519 [file] [log] [blame]
// Copyright (C) 2014 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.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.data.AccountInfo;
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.server.GerritPersonIdent;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
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.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.Charset;
import java.sql.Timestamp;
import java.text.ParseException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* Utility functions to parse PatchLineComments out of a note byte array and
* store a list of PatchLineComments in the form of a note (in a byte array).
**/
@Singleton
public class CommentsInNotesUtil {
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 PATCH_SET = "Patch-set";
private static final String REVISION = "Revision";
private static final String UUID = "UUID";
private static final int MAX_NOTE_SZ = 25 << 20;
public static NoteMap parseCommentsFromNotes(Repository repo, String refName,
RevWalk walk, Change.Id changeId,
Multimap<PatchSet.Id, PatchLineComment> commentsForBase,
Multimap<PatchSet.Id, PatchLineComment> commentsForPs,
Status status)
throws IOException, ConfigInvalidException {
Ref ref = repo.getRef(refName);
if (ref == null) {
return null;
}
ObjectReader reader = walk.getObjectReader();
RevCommit commit = walk.parseCommit(ref.getObjectId());
NoteMap noteMap = NoteMap.read(reader, commit);
for (Note note: noteMap) {
byte[] bytes =
reader.open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
List<PatchLineComment> result = parseNote(bytes, changeId, status);
if (result == null || result.isEmpty()) {
continue;
}
PatchSet.Id psId = result.get(0).getKey().getParentKey().getParentKey();
short side = result.get(0).getSide();
if (side == 0) {
commentsForBase.putAll(psId, result);
} else {
commentsForPs.putAll(psId, result);
}
}
return noteMap;
}
public static List<PatchLineComment> parseNote(byte[] note,
Change.Id changeId, Status status) throws ConfigInvalidException {
List<PatchLineComment> result = Lists.newArrayList();
int sizeOfNote = note.length;
Charset enc = RawParseUtils.parseEncoding(note);
MutableInteger curr = new MutableInteger();
curr.value = 0;
boolean isForBase =
(RawParseUtils.match(note, curr.value, PATCH_SET.getBytes(UTF_8))) < 0;
PatchSet.Id psId = parsePsId(note, curr, changeId, enc,
isForBase ? BASE_PATCH_SET : PATCH_SET);
RevId revId =
new RevId(parseStringField(note, curr, changeId, enc, REVISION));
PatchLineComment c = null;
while (curr.value < sizeOfNote) {
String previousFileName = c == null ?
null : c.getKey().getParentKey().getFileName();
c = parseComment(note, curr, previousFileName, psId, revId,
isForBase, enc, status);
result.add(c);
}
return result;
}
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);
}
public static PatchSet.Id getCommentPsId(PatchLineComment plc) {
return plc.getKey().getParentKey().getParentKey();
}
private static PatchLineComment parseComment(byte[] note, MutableInteger curr,
String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
Charset enc, 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, enc);
} 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, enc);
Account.Id aId = parseAuthor(note, curr, changeId, enc);
boolean hasParent =
(RawParseUtils.match(note, curr.value, PARENT.getBytes(enc))) != -1;
String parentUUID = null;
if (hasParent) {
parentUUID = parseStringField(note, curr, changeId, enc, PARENT);
}
String uuid = parseStringField(note, curr, changeId, enc, UUID);
int commentLength = parseCommentLength(note, curr, changeId, enc);
String message = RawParseUtils.decode(
enc, 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.setSide((short) (isForBase ? 0 : 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, Charset enc, String fieldName)
throws ConfigInvalidException {
int endOfLine = RawParseUtils.nextLF(note, curr.value);
checkHeaderLineFormat(note, curr, fieldName, enc, changeId);
int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
curr.value = endOfLine;
return RawParseUtils.decode(enc, 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 startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (startLine == 0) {
range.setEndLine(0);
ptr.value += 1;
return range;
}
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;
}
int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (startChar == 0) {
return null;
}
if (note[ptr.value] == '-') {
range.setStartCharacter(startChar);
ptr.value += 1;
} else {
return null;
}
int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (endLine == 0) {
return null;
}
if (note[ptr.value] == ':') {
range.setEndLine(endLine);
ptr.value += 1;
} else {
return null;
}
int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
if (endChar == 0) {
return null;
}
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, Charset enc, String fieldName)
throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, fieldName, enc, 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 String parseFilename(byte[] note, MutableInteger curr,
Change.Id changeId, Charset enc) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, FILE, enc, 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(enc, note, startOfFileName, endOfLine - 1));
}
private static Timestamp parseTimestamp(byte[] note, MutableInteger curr,
Change.Id changeId, Charset enc)
throws ConfigInvalidException {
int endOfLine = RawParseUtils.nextLF(note, curr.value);
Timestamp commentTime;
String dateString =
RawParseUtils.decode(enc, note, curr.value, endOfLine - 1);
try {
commentTime =
new Timestamp(GitDateParser.parse(dateString, null).getTime());
} catch (ParseException e) {
throw new ConfigInvalidException("could not parse comment timestamp", e);
}
curr.value = endOfLine;
return checkResult(commentTime, "comment timestamp", changeId);
}
private static Account.Id parseAuthor(byte[] note, MutableInteger curr,
Change.Id changeId, Charset enc) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, AUTHOR, enc, 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, Charset enc) throws ConfigInvalidException {
checkHeaderLineFormat(note, curr, LENGTH, enc, changeId);
int startOfLength =
RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
MutableInteger i = new MutableInteger();
int commentLength =
RawParseUtils.parseBase10(note, startOfLength, i);
int endOfLine = RawParseUtils.nextLF(note, curr.value);
if (i.value != endOfLine-1) {
throw parseException(changeId, "could not parse %s", PATCH_SET);
}
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 PersonIdent newIdent(Account author, Date when) {
return new PersonIdent(
new AccountInfo(author).getName(anonymousCowardName),
author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
when, serverIdent.getTimeZone());
}
private static 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());
Integer id = Ints.tryParse(email.substring(0, at));
if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
return new Account.Id(id);
}
}
throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
GERRIT_PLACEHOLDER_HOST, email);
}
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, Charset enc, Change.Id changeId)
throws ConfigInvalidException {
boolean correct =
RawParseUtils.match(note, curr.value, fieldName.getBytes(enc)) != -1;
correct &= (note[curr.value + fieldName.length()] == ':');
correct &= (note[curr.value + fieldName.length() + 1] == ' ');
if (!correct) {
throw parseException(changeId, "could not parse %s", fieldName);
}
}
private final AccountCache accountCache;
private final PersonIdent serverIdent;
private final String anonymousCowardName;
@Inject
public CommentsInNotesUtil(AccountCache accountCache,
@GerritPersonIdent PersonIdent serverIdent,
@AnonymousCowardName String anonymousCowardName) {
this.accountCache = accountCache;
this.serverIdent = serverIdent;
this.anonymousCowardName = anonymousCowardName;
}
/**
* Build a note that contains the metadata for and the contents of all of the
* comments in the given list of comments.
*
* @param comments
* A list of the comments to be written to the returned note
* byte array.
* All of the comments in this list must have the same side and
* must share the same PatchSet.Id.
* This list must not be empty because we cannot build a note
* for no comments.
* @return the note. Null if there are no comments in the list.
*/
public byte[] buildNote(List<PatchLineComment> comments) {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
OutputStreamWriter streamWriter = new OutputStreamWriter(buf, UTF_8);
PrintWriter writer = new PrintWriter(streamWriter);
PatchLineComment first = comments.get(0);
short side = first.getSide();
PatchSet.Id psId = getCommentPsId(first);
appendHeaderField(writer, side == 0
? BASE_PATCH_SET
: PATCH_SET,
Integer.toString(psId.get()));
appendHeaderField(writer, REVISION, first.getRevId().get());
String currentFilename = null;
for (PatchLineComment c : comments) {
PatchSet.Id currentPsId = getCommentPsId(c);
checkArgument(psId.equals(currentPsId),
"All comments being added must all have the same PatchSet.Id. The"
+ "comment below does not have the same PatchSet.Id as the others "
+ "(%s).\n%s", psId.toString(), c.toString());
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.toString());
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");
}
// The CommentRange field for a comment is allowed to be null.
// If it is indeed 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());
String nameString = ident.getName() + " <" + ident.getEmailAddress()
+ ">";
appendHeaderField(writer, AUTHOR, nameString);
String parent = c.getParentUuid();
if (parent != null) {
appendHeaderField(writer, PARENT, parent);
}
appendHeaderField(writer, UUID, c.getKey().get());
byte[] messageBytes = c.getMessage().getBytes(UTF_8);
appendHeaderField(writer, LENGTH,
Integer.toString(messageBytes.length));
writer.print(c.getMessage());
writer.print("\n\n");
}
writer.close();
return buf.toByteArray();
}
public void writeCommentsToNoteMap(NoteMap noteMap,
List<PatchLineComment> allComments, ObjectInserter inserter)
throws IOException {
checkArgument(!allComments.isEmpty(),
"No comments to write; to delete, use removeNoteFromNoteMap().");
ObjectId commit =
ObjectId.fromString(allComments.get(0).getRevId().get());
Collections.sort(allComments, ChangeNotes.PLC_ORDER);
noteMap.set(commit, inserter.insert(OBJ_BLOB, buildNote(allComments)));
}
public void removeNote(NoteMap noteMap, RevId commitId)
throws IOException {
noteMap.remove(ObjectId.fromString(commitId.get()));
}
}