| // Copyright (C) 2022 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.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL; | |
| import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL; | |
| import com.google.auto.value.AutoValue; | |
| import com.google.common.base.Splitter; | |
| import com.google.common.base.Strings; | |
| import java.util.ArrayList; | |
| import java.util.List; | |
| import java.util.Optional; | |
| import org.eclipse.jgit.errors.ConfigInvalidException; | |
| import org.eclipse.jgit.revwalk.FooterKey; | |
| /** | |
| * Util to extract {@link com.google.gerrit.entities.PatchSetApproval} from {@link | |
| * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}. | |
| */ | |
| public class ChangeNotesParseApprovalUtil { | |
| private static final String LABEL_VOTE_UUID_SEPARATOR = ", "; | |
| /** | |
| * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link | |
| * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#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_ChangeNotesParseApprovalUtil_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 ChangeNoteFooters#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 ChangeNoteFooters#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 ChangeNoteFooters#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 ChangeNoteFooters#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> | |
| * | |
| * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account> | |
| * | |
| * <ul> | |
| * <li><Gerrit Real Account> is also optional, if it was not set. | |
| * </ul> | |
| */ | |
| public static ParsedPatchSetApproval parseCopiedApproval(String labelLine) | |
| throws ConfigInvalidException { | |
| try { | |
| ParsedPatchSetApproval.Builder rawPatchSetApproval = | |
| ParsedPatchSetApproval.builder().footerLine(labelLine); | |
| boolean isRemoval = labelLine.startsWith("-"); | |
| rawPatchSetApproval.isRemoval(isRemoval); | |
| int labelStart = isRemoval ? 1 : 0; | |
| int tagStart = isRemoval ? -1 : labelLine.indexOf(":\""); | |
| int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart); | |
| checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine); | |
| // 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(labelStart, 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)); | |
| 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); | |
| } | |
| } |