blob: 739bd38097bc8e8fa21f801ca08ffc36f57491ed [file] [log] [blame]
// 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}: &quot;cover letter&quot; 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();
}
}