| // Copyright (C) 2008 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.entities; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.gerrit.entities.RefNames.REFS_CHANGES; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| import com.google.auto.value.AutoValue; |
| import com.google.common.primitives.Ints; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.extensions.client.ChangeStatus; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.SecureRandom; |
| import java.sql.Timestamp; |
| import java.util.Arrays; |
| import org.eclipse.jgit.lib.Constants; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.ObjectInserter; |
| |
| /** |
| * A change proposed to be merged into a branch. |
| * |
| * <p>The data graph rooted below a Change can be quite complex: |
| * |
| * <pre> |
| * {@link Change} |
| * | |
| * +- {@link ChangeMessage}: "cover letter" or general comment. |
| * | |
| * +- {@link PatchSet}: a single variant of this change. |
| * | |
| * +- {@link PatchSetApproval}: a +/- vote on the change's current state. |
| * | |
| * +- {@link Comment}: comment about a specific line |
| * </pre> |
| * |
| * <p> |
| * |
| * <h5>PatchSets</h5> |
| * |
| * <p>Every change has at least one PatchSet. A change starts out with one PatchSet, the initial |
| * proposal put forth by the change owner. This {@link Account} is usually also listed as the author |
| * and committer in the PatchSetInfo. |
| * |
| * <p>Each PatchSet contains zero or more Patch records, detailing the file paths impacted by the |
| * change (otherwise known as, the file paths the author added/deleted/modified). Sometimes a merge |
| * commit can contain zero patches, if the merge has no conflicts, or has no impact other than to |
| * cut off a line of development. |
| * |
| * <p>Each Comment is a draft or a published comment about a single line of the associated file. |
| * These are the inline comment entities created by users as they perform a review. |
| * |
| * <p>When additional PatchSets appear under a change, these PatchSets reference <i>replacement</i> |
| * commits; alternative commits that could be made to the project instead of the original commit |
| * referenced by the first PatchSet. |
| * |
| * <p>A change has at most one current PatchSet. The current PatchSet is updated when a new |
| * replacement PatchSet is uploaded. When a change is submitted, the current patch set is what is |
| * merged into the destination branch. |
| * |
| * <p> |
| * |
| * <h5>ChangeMessage</h5> |
| * |
| * <p>The ChangeMessage entity is a general free-form comment about the whole change, rather than |
| * Comment's file and line specific context. The ChangeMessage appears at the start of any email |
| * generated by Gerrit, and is shown on the change overview page, rather than in a file-specific |
| * context. Users often use this entity to describe general remarks about the overall concept |
| * proposed by the change. |
| * |
| * <p> |
| * |
| * <h5>PatchSetApproval</h5> |
| * |
| * <p>PatchSetApproval entities exist to fill in the <i>cells</i> of the approvals table in the web |
| * UI. That is, a single PatchSetApproval record's key is the tuple {@code |
| * (PatchSet,Account,ApprovalCategory)}. Each PatchSetApproval carries with it a small score value, |
| * typically within the range -2..+2. |
| * |
| * <p>If an Account has created only PatchSetApprovals with a score value of 0, the Change shows in |
| * their dashboard, and they are said to be CC'd (carbon copied) on the Change, but are not a direct |
| * reviewer. This often happens when an account was specified at upload time with the {@code --cc} |
| * command line flag, or have published comments, but left the approval scores at 0 ("No Score"). |
| * |
| * <p>If an Account has one or more PatchSetApprovals with a score != 0, the Change shows in their |
| * dashboard, and they are said to be an active reviewer. Such individuals are highlighted when |
| * notice of a replacement patch set is sent, or when notice of the change submission occurs. |
| */ |
| public final class Change { |
| private static final SecureRandom rng; |
| |
| static { |
| try { |
| rng = SecureRandom.getInstance("SHA1PRNG"); |
| } catch (NoSuchAlgorithmException e) { |
| throw new RuntimeException("Cannot create RNG for Change-Id generator", e); |
| } |
| } |
| |
| public static Id id(int id) { |
| return new AutoValue_Change_Id(id); |
| } |
| |
| @AutoValue |
| public abstract static class Id { |
| /** Parse a Change.Id out of a string representation. */ |
| public static Id parse(String str) { |
| Integer id = Ints.tryParse(str); |
| checkArgument(id != null, "invalid change ID: %s", str); |
| return Change.id(id); |
| } |
| |
| public static Id fromRef(String ref) { |
| if (RefNames.isRefsEdit(ref)) { |
| return fromEditRefPart(ref); |
| } |
| int cs = startIndex(ref); |
| if (cs < 0) { |
| return null; |
| } |
| int ce = nextNonDigit(ref, cs); |
| if (ref.substring(ce).equals(RefNames.META_SUFFIX) |
| || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX) |
| || PatchSet.Id.fromRef(ref, ce) >= 0) { |
| return Change.id(Integer.parseInt(ref.substring(cs, ce))); |
| } |
| return null; |
| } |
| |
| public static Id fromAllUsersRef(String ref) { |
| if (ref == null) { |
| return null; |
| } |
| String prefix; |
| if (ref.startsWith(RefNames.REFS_STARRED_CHANGES)) { |
| prefix = RefNames.REFS_STARRED_CHANGES; |
| } else if (ref.startsWith(RefNames.REFS_DRAFT_COMMENTS)) { |
| prefix = RefNames.REFS_DRAFT_COMMENTS; |
| } else { |
| return null; |
| } |
| int cs = startIndex(ref, prefix); |
| if (cs < 0) { |
| return null; |
| } |
| int ce = nextNonDigit(ref, cs); |
| if (ce < ref.length() && ref.charAt(ce) == '/' && isNumeric(ref, ce + 1)) { |
| return Change.id(Integer.parseInt(ref.substring(cs, ce))); |
| } |
| return null; |
| } |
| |
| private static boolean isNumeric(String s, int off) { |
| if (off >= s.length()) { |
| return false; |
| } |
| for (int i = off; i < s.length(); i++) { |
| if (!Character.isDigit(s.charAt(i))) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| public static Id fromEditRefPart(String ref) { |
| int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length(); |
| int endChangeId = nextNonDigit(ref, startChangeId); |
| String id = ref.substring(startChangeId, endChangeId); |
| if (id != null && !id.isEmpty()) { |
| return Change.id(Integer.parseInt(id)); |
| } |
| return null; |
| } |
| |
| public static Id fromRefPart(String ref) { |
| Integer id = RefNames.parseShardedRefPart(ref); |
| return id != null ? Change.id(id) : null; |
| } |
| |
| static int startIndex(String ref) { |
| return startIndex(ref, REFS_CHANGES); |
| } |
| |
| static int startIndex(String ref, String expectedPrefix) { |
| if (ref == null || !ref.startsWith(expectedPrefix)) { |
| return -1; |
| } |
| |
| // Last 2 digits. |
| int ls = expectedPrefix.length(); |
| int le = nextNonDigit(ref, ls); |
| if (le - ls != 2 || le >= ref.length() || ref.charAt(le) != '/') { |
| return -1; |
| } |
| |
| // Change ID. |
| int cs = le + 1; |
| if (cs >= ref.length() || ref.charAt(cs) == '0') { |
| return -1; |
| } |
| int ce = nextNonDigit(ref, cs); |
| if (ce >= ref.length() || ref.charAt(ce) != '/') { |
| return -1; |
| } |
| switch (ce - cs) { |
| case 0: |
| return -1; |
| case 1: |
| if (ref.charAt(ls) != '0' || ref.charAt(ls + 1) != ref.charAt(cs)) { |
| return -1; |
| } |
| break; |
| default: |
| if (ref.charAt(ls) != ref.charAt(ce - 2) || ref.charAt(ls + 1) != ref.charAt(ce - 1)) { |
| return -1; |
| } |
| break; |
| } |
| return cs; |
| } |
| |
| static int nextNonDigit(String s, int i) { |
| while (i < s.length() && s.charAt(i) >= '0' && s.charAt(i) <= '9') { |
| i++; |
| } |
| return i; |
| } |
| |
| abstract int id(); |
| |
| public int get() { |
| return id(); |
| } |
| |
| public String toRefPrefix() { |
| return refPrefixBuilder().toString(); |
| } |
| |
| StringBuilder refPrefixBuilder() { |
| StringBuilder r = new StringBuilder(32).append(REFS_CHANGES); |
| int m = get() % 100; |
| if (m < 10) { |
| r.append('0'); |
| } |
| return r.append(m).append('/').append(get()).append('/'); |
| } |
| |
| @Override |
| public final String toString() { |
| return Integer.toString(get()); |
| } |
| } |
| |
| public static ObjectId generateChangeId() { |
| byte[] rand = new byte[Constants.OBJECT_ID_STRING_LENGTH]; |
| rng.nextBytes(rand); |
| String randomString = new String(rand, UTF_8); |
| |
| try (ObjectInserter f = new ObjectInserter.Formatter()) { |
| return f.idFor(Constants.OBJ_COMMIT, Constants.encode(randomString)); |
| } |
| } |
| |
| public static Key generateKey() { |
| return key("I" + generateChangeId().name()); |
| } |
| |
| public static Key key(String key) { |
| return new AutoValue_Change_Key(key); |
| } |
| |
| /** |
| * Globally unique identification of this change. This generally takes the form of a string |
| * "Ixxxxxx...", and is stored in the Change-Id footer of a commit. |
| */ |
| @AutoValue |
| public abstract static class Key { |
| // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key? |
| // Ideally the standard key() factory method would enforce the format and throw IAE. |
| public static Key parse(String str) { |
| return Change.key(KeyUtil.decode(str)); |
| } |
| |
| abstract String key(); |
| |
| public String get() { |
| return key(); |
| } |
| |
| /** Construct a key that is after all keys prefixed by this key. */ |
| public Key max() { |
| final StringBuilder revEnd = new StringBuilder(get().length() + 1); |
| revEnd.append(get()); |
| revEnd.append('\u9fa5'); |
| return Change.key(revEnd.toString()); |
| } |
| |
| /** Obtain a shorter version of this key string, using a leading prefix. */ |
| public String abbreviate() { |
| final String s = get(); |
| return s.substring(0, Math.min(s.length(), 9)); |
| } |
| |
| @Override |
| public final String toString() { |
| return get(); |
| } |
| } |
| |
| /** Minimum database status constant for an open change. */ |
| private static final char MIN_OPEN = 'a'; |
| /** Database constant for {@link Status#NEW}. */ |
| public static final char STATUS_NEW = 'n'; |
| /** Maximum database status constant for an open change. */ |
| private static final char MAX_OPEN = 'z'; |
| |
| /** Database constant for {@link Status#MERGED}. */ |
| public static final char STATUS_MERGED = 'M'; |
| |
| /** ID number of the first patch set in a change. */ |
| public static final int INITIAL_PATCH_SET_ID = 1; |
| |
| /** Change-Id pattern. */ |
| public static final String CHANGE_ID_PATTERN = "^[iI][0-9a-f]{4,}.*$"; |
| |
| /** |
| * Current state within the basic workflow of the change. |
| * |
| * <p>Within the database, lower case codes ('a'..'z') indicate a change that is still open, and |
| * that can be modified/refined further, while upper case codes ('A'..'Z') indicate a change that |
| * is closed and cannot be further modified. |
| */ |
| public enum Status { |
| /** |
| * Change is open and pending review, or review is in progress. |
| * |
| * <p>This is the default state assigned to a change when it is first created in the database. A |
| * change stays in the NEW state throughout its review cycle, until the change is submitted or |
| * abandoned. |
| * |
| * <p>Changes in the NEW state can be moved to: |
| * |
| * <ul> |
| * <li>{@link #MERGED} - when the Submit Patch Set action is used; |
| * <li>{@link #ABANDONED} - when the Abandon action is used. |
| * </ul> |
| */ |
| NEW(STATUS_NEW, ChangeStatus.NEW), |
| |
| /** |
| * Change is closed, and submitted to its destination branch. |
| * |
| * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch |
| */ |
| MERGED(STATUS_MERGED, ChangeStatus.MERGED), |
| |
| /** |
| * Change is closed, but was not submitted to its destination branch. |
| * |
| * <p>Once a change has been abandoned, it cannot be further modified by adding a replacement |
| * patch set, and it cannot be merged. Draft comments however may be published, permitting |
| * reviewers to send constructive feedback. |
| */ |
| ABANDONED('A', ChangeStatus.ABANDONED); |
| |
| static { |
| boolean ok = true; |
| if (Status.values().length != ChangeStatus.values().length) { |
| ok = false; |
| } |
| for (Status s : Status.values()) { |
| ok &= s.name().equals(s.changeStatus.name()); |
| } |
| if (!ok) { |
| throw new IllegalStateException( |
| "Mismatched status mapping: " |
| + Arrays.asList(Status.values()) |
| + " != " |
| + Arrays.asList(ChangeStatus.values())); |
| } |
| } |
| |
| private final char code; |
| private final boolean closed; |
| private final ChangeStatus changeStatus; |
| |
| Status(char c, ChangeStatus cs) { |
| code = c; |
| closed = !(MIN_OPEN <= c && c <= MAX_OPEN); |
| changeStatus = cs; |
| } |
| |
| public char getCode() { |
| return code; |
| } |
| |
| public boolean isOpen() { |
| return !closed; |
| } |
| |
| public boolean isClosed() { |
| return closed; |
| } |
| |
| public ChangeStatus asChangeStatus() { |
| return changeStatus; |
| } |
| |
| public static Status forCode(char c) { |
| for (Status s : Status.values()) { |
| if (s.code == c) { |
| return s; |
| } |
| } |
| |
| return null; |
| } |
| |
| public static Status forChangeStatus(ChangeStatus cs) { |
| for (Status s : Status.values()) { |
| if (s.changeStatus == cs) { |
| return s; |
| } |
| } |
| return null; |
| } |
| } |
| |
| /** Locally assigned unique identifier of the change */ |
| protected Id changeId; |
| |
| /** Globally assigned unique identifier of the change */ |
| protected Key changeKey; |
| |
| /** optimistic locking */ |
| protected int rowVersion; |
| |
| /** When this change was first introduced into the database. */ |
| protected Timestamp createdOn; |
| |
| /** |
| * When was a meaningful modification last made to this record's data |
| * |
| * <p>Note, this update timestamp includes its children. |
| */ |
| protected Timestamp lastUpdatedOn; |
| |
| // DELETED: id = 6 (sortkey) |
| |
| protected Account.Id owner; |
| |
| /** The branch (and project) this change merges into. */ |
| protected BranchNameKey dest; |
| |
| // DELETED: id = 9 (open) |
| |
| /** Current state code; see {@link Status}. */ |
| protected char status; |
| |
| // DELETED: id = 11 (nbrPatchSets) |
| |
| /** The current patch set. */ |
| protected int currentPatchSetId; |
| |
| /** Subject from the current patch set. */ |
| protected String subject; |
| |
| /** Topic name assigned by the user, if any. */ |
| @Nullable protected String topic; |
| |
| // DELETED: id = 15 (lastSha1MergeTested) |
| // DELETED: id = 16 (mergeable) |
| |
| /** |
| * First line of first patch set's commit message. |
| * |
| * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first |
| * line. |
| */ |
| @Nullable protected String originalSubject; |
| |
| /** |
| * Unique id for the changes submitted together assigned during merging. Only set if the status is |
| * MERGED. |
| */ |
| @Nullable protected String submissionId; |
| |
| /** Allows assigning a change to a user. */ |
| @Nullable protected Account.Id assignee; |
| |
| /** Whether the change is private. */ |
| protected boolean isPrivate; |
| |
| /** Whether the change is work in progress. */ |
| protected boolean workInProgress; |
| |
| /** Whether the change has started review. */ |
| protected boolean reviewStarted; |
| |
| /** References a change that this change reverts. */ |
| @Nullable protected Id revertOf; |
| |
| protected Change() {} |
| |
| public Change( |
| Change.Key newKey, |
| Change.Id newId, |
| Account.Id ownedBy, |
| BranchNameKey forBranch, |
| Timestamp ts) { |
| changeKey = newKey; |
| changeId = newId; |
| createdOn = ts; |
| lastUpdatedOn = createdOn; |
| owner = ownedBy; |
| dest = forBranch; |
| setStatus(Status.NEW); |
| } |
| |
| public Change(Change other) { |
| assignee = other.assignee; |
| changeId = other.changeId; |
| changeKey = other.changeKey; |
| rowVersion = other.rowVersion; |
| createdOn = other.createdOn; |
| lastUpdatedOn = other.lastUpdatedOn; |
| owner = other.owner; |
| dest = other.dest; |
| status = other.status; |
| currentPatchSetId = other.currentPatchSetId; |
| subject = other.subject; |
| originalSubject = other.originalSubject; |
| submissionId = other.submissionId; |
| topic = other.topic; |
| isPrivate = other.isPrivate; |
| workInProgress = other.workInProgress; |
| reviewStarted = other.reviewStarted; |
| revertOf = other.revertOf; |
| } |
| |
| /** Legacy 32 bit integer identity for a change. */ |
| public Change.Id getId() { |
| return changeId; |
| } |
| |
| /** Legacy 32 bit integer identity for a change. */ |
| public int getChangeId() { |
| return changeId.get(); |
| } |
| |
| /** The Change-Id tag out of the initial commit, or a natural key. */ |
| public Change.Key getKey() { |
| return changeKey; |
| } |
| |
| public void setKey(Change.Key k) { |
| changeKey = k; |
| } |
| |
| public Account.Id getAssignee() { |
| return assignee; |
| } |
| |
| public void setAssignee(Account.Id a) { |
| assignee = a; |
| } |
| |
| public Timestamp getCreatedOn() { |
| return createdOn; |
| } |
| |
| public void setCreatedOn(Timestamp ts) { |
| createdOn = ts; |
| } |
| |
| public Timestamp getLastUpdatedOn() { |
| return lastUpdatedOn; |
| } |
| |
| public void setLastUpdatedOn(Timestamp now) { |
| lastUpdatedOn = now; |
| } |
| |
| public int getRowVersion() { |
| return rowVersion; |
| } |
| |
| public Account.Id getOwner() { |
| return owner; |
| } |
| |
| public void setOwner(Account.Id owner) { |
| this.owner = owner; |
| } |
| |
| public BranchNameKey getDest() { |
| return dest; |
| } |
| |
| public void setDest(BranchNameKey dest) { |
| this.dest = dest; |
| } |
| |
| public Project.NameKey getProject() { |
| return dest.project(); |
| } |
| |
| public String getSubject() { |
| return subject; |
| } |
| |
| public String getOriginalSubject() { |
| return originalSubject != null ? originalSubject : subject; |
| } |
| |
| public String getOriginalSubjectOrNull() { |
| return originalSubject; |
| } |
| |
| /** Get the id of the most current {@link PatchSet} in this change. */ |
| public PatchSet.Id currentPatchSetId() { |
| if (currentPatchSetId > 0) { |
| return PatchSet.id(changeId, currentPatchSetId); |
| } |
| return null; |
| } |
| |
| public void setCurrentPatchSet(PatchSetInfo ps) { |
| if (originalSubject == null && subject != null) { |
| // Change was created before schema upgrade. Use the last subject |
| // associated with this change, as the most recent discussion will |
| // be under that thread in an email client such as GMail. |
| originalSubject = subject; |
| } |
| |
| currentPatchSetId = ps.getKey().get(); |
| subject = ps.getSubject(); |
| |
| if (originalSubject == null) { |
| // Newly created changes remember the first commit's subject. |
| originalSubject = subject; |
| } |
| } |
| |
| public void setCurrentPatchSet(PatchSet.Id psId, String subject, String originalSubject) { |
| if (!psId.changeId().equals(changeId)) { |
| throw new IllegalArgumentException("patch set ID " + psId + " is not for change " + changeId); |
| } |
| currentPatchSetId = psId.get(); |
| this.subject = subject; |
| this.originalSubject = originalSubject; |
| } |
| |
| public void clearCurrentPatchSet() { |
| currentPatchSetId = 0; |
| subject = null; |
| originalSubject = null; |
| } |
| |
| public String getSubmissionId() { |
| return submissionId; |
| } |
| |
| public void setSubmissionId(String id) { |
| this.submissionId = id; |
| } |
| |
| public Status getStatus() { |
| return Status.forCode(status); |
| } |
| |
| public void setStatus(Status newStatus) { |
| status = newStatus.getCode(); |
| } |
| |
| public boolean isNew() { |
| return getStatus().equals(Status.NEW); |
| } |
| |
| public boolean isMerged() { |
| return getStatus().equals(Status.MERGED); |
| } |
| |
| public boolean isAbandoned() { |
| return getStatus().equals(Status.ABANDONED); |
| } |
| |
| public boolean isClosed() { |
| return isAbandoned() || isMerged(); |
| } |
| |
| public String getTopic() { |
| return topic; |
| } |
| |
| public void setTopic(String topic) { |
| this.topic = topic; |
| } |
| |
| public boolean isPrivate() { |
| return isPrivate; |
| } |
| |
| public void setPrivate(boolean isPrivate) { |
| this.isPrivate = isPrivate; |
| } |
| |
| public boolean isWorkInProgress() { |
| return workInProgress; |
| } |
| |
| public void setWorkInProgress(boolean workInProgress) { |
| this.workInProgress = workInProgress; |
| } |
| |
| public boolean hasReviewStarted() { |
| return reviewStarted; |
| } |
| |
| public void setReviewStarted(boolean reviewStarted) { |
| this.reviewStarted = reviewStarted; |
| } |
| |
| public void setRevertOf(Id revertOf) { |
| this.revertOf = revertOf; |
| } |
| |
| public Id getRevertOf() { |
| return this.revertOf; |
| } |
| |
| @Override |
| public String toString() { |
| return new StringBuilder(getClass().getSimpleName()) |
| .append('{') |
| .append(changeId) |
| .append(" (") |
| .append(changeKey) |
| .append("), ") |
| .append("dest=") |
| .append(dest) |
| .append(", ") |
| .append("status=") |
| .append(status) |
| .append('}') |
| .toString(); |
| } |
| } |