diff options
Diffstat (limited to 'java/com/google/gerrit/server/notedb/ChangeNoteUtil.java')
-rw-r--r-- | java/com/google/gerrit/server/notedb/ChangeNoteUtil.java | 349 |
1 files changed, 323 insertions, 26 deletions
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java index 28ab7114c6..6d04791320 100644 --- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java +++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java @@ -15,6 +15,8 @@ package com.google.gerrit.server.notedb; import com.google.auto.value.AutoValue; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.AttentionSetUpdate; import com.google.gerrit.json.OutputFormat; @@ -22,8 +24,10 @@ import com.google.gerrit.server.config.GerritServerId; import com.google.gson.Gson; import com.google.inject.Inject; import java.time.Instant; -import java.util.Date; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.FooterKey; import org.eclipse.jgit.revwalk.RevCommit; @@ -31,33 +35,35 @@ import org.eclipse.jgit.util.RawParseUtils; public class ChangeNoteUtil { - static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention"); - static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee"); - 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_CURRENT = new FooterKey("Current"); - 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_COPIED_LABEL = new FooterKey("Copied-Label"); - static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set"); - static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description"); - static final FooterKey FOOTER_PRIVATE = new FooterKey("Private"); - static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user"); - 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"); - static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress"); - static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of"); - static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of"); + public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention"); + public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee"); + public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch"); + public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id"); + public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit"); + public static final FooterKey FOOTER_CURRENT = new FooterKey("Current"); + public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups"); + public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags"); + public static final FooterKey FOOTER_LABEL = new FooterKey("Label"); + public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label"); + public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set"); + public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = + new FooterKey("Patch-set-description"); + public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private"); + public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user"); + public static final FooterKey FOOTER_STATUS = new FooterKey("Status"); + public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject"); + public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id"); + public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with"); + public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic"); + public static final FooterKey FOOTER_TAG = new FooterKey("Tag"); + public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress"); + public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of"); + public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of"); static final String GERRIT_USER_TEMPLATE = "Gerrit User %d"; private static final Gson gson = OutputFormat.JSON_COMPACT.newGson(); + private static final String LABEL_VOTE_UUID_SEPARATOR = ", "; private final ChangeNoteJson changeNoteJson; private final String serverId; @@ -95,12 +101,13 @@ public class ChangeNoteUtil { * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email * address. */ - public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) { + public PersonIdent newAccountIdIdent( + Account.Id accountId, Instant when, PersonIdent serverIdent) { return new PersonIdent( getAccountIdAsUsername(accountId), getAccountIdAsEmailAddress(accountId), when, - serverIdent.getTimeZone()); + serverIdent.getZoneId()); } /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */ @@ -245,4 +252,294 @@ public class ChangeNoteUtil { new AttentionStatusInNoteDb( stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason())); } + + /** + * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link #FOOTER_LABEL} or + * {@link #FOOTER_COPIED_LABEL}. + * + * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent + * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed + * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link + * #footerLine} values. + */ + @AutoValue + public abstract static class ParsedPatchSetApproval { + + /** The original footer value, that this entity was parsed from. */ + public abstract String footerLine(); + + public abstract boolean isRemoval(); + + /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */ + public abstract String labelVote(); + + public abstract Optional<String> uuid(); + + public abstract Optional<String> accountIdent(); + + public abstract Optional<String> realAccountIdent(); + + public abstract Optional<String> tag(); + + public static Builder builder() { + return new AutoValue_ChangeNoteUtil_ParsedPatchSetApproval.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + + abstract Builder footerLine(String labelLine); + + abstract Builder isRemoval(boolean isRemoval); + + abstract Builder labelVote(String labelVote); + + abstract Builder uuid(Optional<String> uuid); + + abstract Builder accountIdent(Optional<String> accountIdent); + + abstract Builder realAccountIdent(Optional<String> realAccountIdent); + + abstract Builder tag(Optional<String> tag); + + abstract ParsedPatchSetApproval build(); + } + } + + /** + * Delegates parsing of {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line to + * dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval} + * correspondingly. + */ + public static ParsedPatchSetApproval parseApproval(String footerLine) + throws ConfigInvalidException { + try { + return footerLine.startsWith("-") + ? parseRemovedApproval(footerLine) + : parseAddedApproval(footerLine); + } catch (StringIndexOutOfBoundsException ex) { + throw parseException(FOOTER_LABEL, footerLine, ex); + } + } + + /** + * Parses added {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line. + * + * <p>Valid added approval footer examples: + * + * <ul> + * <li>Label: <LABEL>=VOTE + * <li>Label: <LABEL>=VOTE <Gerrit Account> + * <li>Label: <LABEL>=VOTE, <UUID> + * <li>Label: <LABEL>=VOTE, <UUID> <Gerrit Account> + * </ul> + * + * <p><UUID> is optional, since the approval might have been granted before {@link + * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced. + * + * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does + * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit). + */ + private static ParsedPatchSetApproval parseAddedApproval(String footerLine) + throws ConfigInvalidException { + ParsedPatchSetApproval.Builder rawPatchSetApproval = + ParsedPatchSetApproval.builder().footerLine(footerLine); + rawPatchSetApproval.isRemoval(false); + // We need some additional logic to differentiate between labels that have a UUID and those that + // have a user with a comma. This allows us to separate the following cases (note that the + // leading `Label: ` has been elided at this point): + // Label: <LABEL>=VOTE, <UUID> <Gerrit Account> + // Label: <LABEL>=VOTE <Gerrit, Account> + int reviewerStartOffset = 0; + int scoreStart = footerLine.indexOf('=') + 1; + StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart)); + for (int i = scoreStart; i < footerLine.length(); i++) { + char currentChar = footerLine.charAt(i); + + // If we hit ',' before ' ' we have a UUID + if (currentChar == ',') { + labelNameScore.append(footerLine, scoreStart, i); + int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length(); + int uuidEnd = footerLine.indexOf(' ', uuidStart); + String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length()); + checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine); + rawPatchSetApproval.uuid(Optional.of(uuid)); + reviewerStartOffset = uuidStart + uuid.length(); + break; + } + + // Otherwise we don't + if (currentChar == ' ') { + labelNameScore.append(footerLine, scoreStart, i); + break; + } + + // If we hit neither we're defensive assign the whole line + if (i == footerLine.length() - 1) { + labelNameScore = new StringBuilder(footerLine); + break; + } + } + + rawPatchSetApproval.labelVote(labelNameScore.toString()); + + int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset); + if (reviewerStart > 0) { + String ident = footerLine.substring(reviewerStart + 1); + rawPatchSetApproval.accountIdent(Optional.of(ident)); + } + return rawPatchSetApproval.build(); + } + + /** + * Parses removed {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line. + * + * <p>Valid removed approval footer examples: + * + * <ul> + * <li>-<LABEL> + * <li>-<LABEL> <Gerrit Account> + * </ul> + * + * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote + * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit). + */ + private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) { + ParsedPatchSetApproval.Builder rawPatchSetApproval = + ParsedPatchSetApproval.builder().footerLine(footerLine); + rawPatchSetApproval.isRemoval(true); + int labelStart = 1; + int reviewerStart = footerLine.indexOf(' ', labelStart); + + rawPatchSetApproval.labelVote( + reviewerStart != -1 + ? footerLine.substring(labelStart, reviewerStart) + : footerLine.substring(labelStart)); + + if (reviewerStart > 0) { + String ident = footerLine.substring(reviewerStart + 1); + rawPatchSetApproval.accountIdent(Optional.of(ident)); + } + return rawPatchSetApproval.build(); + } + + /** + * Parses copied {@link ParsedPatchSetApproval} from {@link #FOOTER_COPIED_LABEL} line. + * + * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account> + * :"<TAG>" + * + * <ul> + * <li>":<"TAG>"" is optional. + * <li><Gerrit Real Account> is also optional, if it was not set. + * <li><UUID> is optional, since the approval might have been granted before {@link + * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced. + * <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit + * Account is also optional since by default it's the committer). + * </ul> + */ + public static ParsedPatchSetApproval parseCopiedApproval(String labelLine) + throws ConfigInvalidException { + try { + // Copied approvals can't be explicitly removed. They are removed the same way as non-copied + // approvals. + checkFooter(!labelLine.startsWith("-"), FOOTER_COPIED_LABEL, labelLine); + ParsedPatchSetApproval.Builder rawPatchSetApproval = + ParsedPatchSetApproval.builder().footerLine(labelLine).isRemoval(false); + + int tagStart = labelLine.indexOf(":\""); + int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart); + + // Weird tag that contains uuid delimiter. The uuid is actually not present. + if (tagStart != -1 && uuidStart > tagStart) { + uuidStart = -1; + } + + int identitiesStart = + labelLine.indexOf( + ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0); + checkFooter( + identitiesStart != -1 && identitiesStart < labelLine.length(), + FOOTER_COPIED_LABEL, + labelLine); + + String labelVoteStr = labelLine.substring(0, uuidStart != -1 ? uuidStart : identitiesStart); + rawPatchSetApproval.labelVote(labelVoteStr); + if (uuidStart != -1) { + String uuid = labelLine.substring(uuidStart + 2, identitiesStart); + checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine); + rawPatchSetApproval.uuid(Optional.of(uuid)); + } + // The first account is the accountId, and second (if applicable) is the realAccountId. + List<String> identities = + parseIdentities( + labelLine.substring( + identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart)); + checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine); + + rawPatchSetApproval.accountIdent(Optional.of(identities.get(0))); + + if (identities.size() > 1) { + rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1))); + } + + if (tagStart != -1) { + // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets. + // line.length()-1 skips the last ". + String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1); + rawPatchSetApproval.tag(Optional.of(tag)); + } + return rawPatchSetApproval.build(); + } catch (StringIndexOutOfBoundsException ex) { + throw parseException(FOOTER_COPIED_LABEL, labelLine, ex); + } + } + + // Return the UUID start index or -1 if no UUID is present + private static int parseCopiedApprovalUuidStart(String line, int tagStart) { + int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR); + + // The first part of the condition checks whether the footer has the following format: + // Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>" + // Weird tag that contains uuid delimiter. The uuid is actually not present. + if ((tagStart != -1 && separatorIndex > tagStart) + || + + // The second part of the condition allows us to distinguish the following two lines: + // Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> + // Label2=+1 User Name (company_name, department) <2@gerrit> + (line.indexOf(' ') < separatorIndex)) { + return -1; + } + return separatorIndex; + } + + // Splitting on "," breaks for identities containing commas. The below re-implements splitting on + // "(?<=>),", but it's 3-5x faster, as performance matters here. + private static List<String> parseIdentities(String line) { + List<String> idents = Splitter.on(',').splitToList(line); + List<String> identitiesList = new ArrayList<>(); + for (int i = 0; i < idents.size(); i++) { + if (i == 0 || idents.get(i - 1).endsWith(">")) { + identitiesList.add(idents.get(i)); + } else { + int lastIndex = identitiesList.size() - 1; + identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i)); + } + } + return identitiesList; + } + + private static void checkFooter(boolean expr, FooterKey footer, String actual) + throws ConfigInvalidException { + if (!expr) { + throw parseException(footer, actual, /*cause=*/ null); + } + } + + private static ConfigInvalidException parseException( + FooterKey footer, String actual, Throwable cause) { + return new ConfigInvalidException( + String.format("invalid %s: %s", footer.getName(), actual), cause); + } } |