diff options
author | James Moger <james.moger@gitblit.com> | 2013-12-09 17:19:03 -0500 |
---|---|---|
committer | James Moger <james.moger@gitblit.com> | 2014-03-03 21:34:32 -0500 |
commit | 5e3521f8496511db4df45f011ea72f25623ad90f (patch) | |
tree | 98b4f516d59833b5a8c1ccbcd45672e5b9f3add2 /src/main/java/com/gitblit/models/TicketModel.java | |
parent | 94e12c168f5eec300fd23d0de25c7dc93a96c429 (diff) | |
download | gitblit-5e3521f8496511db4df45f011ea72f25623ad90f.tar.gz gitblit-5e3521f8496511db4df45f011ea72f25623ad90f.zip |
Ticket tracker with patchset contributions
A basic issue tracker styled as a hybrid of GitHub and BitBucket issues.
You may attach commits to an existing ticket or you can push a single
commit to create a *proposal* ticket.
Tickets keep track of patchsets (one or more commits) and allow patchset
rewriting (rebase, amend, squash) by detecing the non-fast-forward
update and assigning a new patchset number to the new commits.
Ticket tracker
--------------
The ticket tracker stores tickets as an append-only journal of changes.
The journals are deserialized and a ticket is built by applying the
journal entries. Tickets are indexed using Apache Lucene and all
queries and searches are executed against this Lucene index.
There is one trade-off to this persistence design: user attributions are
non-relational.
What does that mean? Each journal entry stores the username of the
author. If the username changes in the user service, the journal entry
will not reflect that change because the values are hard-coded.
Here are a few reasons/justifications for this design choice:
1. commit identifications (author, committer, tagger) are non-relational
2. maintains the KISS principle
3. your favorite text editor can still be your administration tool
Persistence Choices
-------------------
**FileTicketService**: stores journals on the filesystem
**BranchTicketService**: stores journals on an orphan branch
**RedisTicketService**: stores journals in a Redis key-value datastore
It should be relatively straight-forward to develop other backends
(MongoDB, etc) as long as the journal design is preserved.
Pushing Commits
---------------
Each push to a ticket is identified as a patchset revision. A patchset
revision may add commits to the patchset (fast-forward) OR a patchset
revision may rewrite history (rebase, squash, rebase+squash, or amend).
Patchset authors should not be afraid to polish, revise, and rewrite
their code before merging into the proposed branch.
Gitblit will create one ref for each patchset. These refs are updated
for fast-forward pushes or created for rewrites. They are formatted as
`refs/tickets/{shard}/{id}/{patchset}`. The *shard* is the last two
digits of the id. If the id < 10, prefix a 0. The *shard* is always
two digits long. The shard's purpose is to ensure Gitblit doesn't
exceed any filesystem directory limits for file creation.
**Creating a Proposal Ticket**
You may create a new change proposal ticket just by pushing a **single
commit** to `refs/for/{branch}` where branch is the proposed integration
branch OR `refs/for/new` or `refs/for/default` which both will use the
default repository branch.
git push origin HEAD:refs/for/new
**Updating a Patchset**
The safe way to update an existing patchset is to push to the patchset
ref.
git push origin HEAD:refs/heads/ticket/{id}
This ensures you do not accidentally create a new patchset in the event
that the patchset was updated after you last pulled.
The not-so-safe way to update an existing patchset is to push using the
magic ref.
git push origin HEAD:refs/for/{id}
This push ref will update an exisitng patchset OR create a new patchset
if the update is non-fast-forward.
**Rebasing, Squashing, Amending**
Gitblit makes rebasing, squashing, and amending patchsets easy.
Normally, pushing a non-fast-forward update would require rewind (RW+)
repository permissions. Gitblit provides a magic ref which will allow
ticket participants to rewrite a ticket patchset as long as the ticket
is open.
git push origin HEAD:refs/for/{id}
Pushing changes to this ref allows the patchset authors to rebase,
squash, or amend the patchset commits without requiring client-side use
of the *--force* flag on push AND without requiring RW+ permission to
the repository. Since each patchset is tracked with a ref it is easy to
recover from accidental non-fast-forward updates.
Features
--------
- Ticket tracker with status changes and responsible assignments
- Patchset revision scoring mechanism
- Update/Rewrite patchset handling
- Close-on-push detection
- Server-side Merge button for simple merges
- Comments with Markdown syntax support
- Rich mail notifications
- Voting
- Mentions
- Watch lists
- Querying
- Searches
- Partial miletones support
- Multiple backend options
Diffstat (limited to 'src/main/java/com/gitblit/models/TicketModel.java')
-rw-r--r-- | src/main/java/com/gitblit/models/TicketModel.java | 1286 |
1 files changed, 1286 insertions, 0 deletions
diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java new file mode 100644 index 00000000..1ff55ddb --- /dev/null +++ b/src/main/java/com/gitblit/models/TicketModel.java @@ -0,0 +1,1286 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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.gitblit.models; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jgit.util.RelativeDateFormatter; + +/** + * The Gitblit Ticket model, its component classes, and enums. + * + * @author James Moger + * + */ +public class TicketModel implements Serializable, Comparable<TicketModel> { + + private static final long serialVersionUID = 1L; + + public String project; + + public String repository; + + public long number; + + public Date created; + + public String createdBy; + + public Date updated; + + public String updatedBy; + + public String title; + + public String body; + + public String topic; + + public Type type; + + public Status status; + + public String responsible; + + public String milestone; + + public String mergeSha; + + public String mergeTo; + + public List<Change> changes; + + public Integer insertions; + + public Integer deletions; + + /** + * Builds an effective ticket from the collection of changes. A change may + * Add or Subtract information from a ticket, but the collection of changes + * is only additive. + * + * @param changes + * @return the effective ticket + */ + public static TicketModel buildTicket(Collection<Change> changes) { + TicketModel ticket; + List<Change> effectiveChanges = new ArrayList<Change>(); + Map<String, Change> comments = new HashMap<String, Change>(); + for (Change change : changes) { + if (change.comment != null) { + if (comments.containsKey(change.comment.id)) { + Change original = comments.get(change.comment.id); + Change clone = copy(original); + clone.comment.text = change.comment.text; + clone.comment.deleted = change.comment.deleted; + int idx = effectiveChanges.indexOf(original); + effectiveChanges.remove(original); + effectiveChanges.add(idx, clone); + comments.put(clone.comment.id, clone); + } else { + effectiveChanges.add(change); + comments.put(change.comment.id, change); + } + } else { + effectiveChanges.add(change); + } + } + + // effective ticket + ticket = new TicketModel(); + for (Change change : effectiveChanges) { + if (!change.hasComment()) { + // ensure we do not include a deleted comment + change.comment = null; + } + ticket.applyChange(change); + } + return ticket; + } + + public TicketModel() { + // the first applied change set the date appropriately + created = new Date(0); + changes = new ArrayList<Change>(); + status = Status.New; + type = Type.defaultType; + } + + public boolean isOpen() { + return !status.isClosed(); + } + + public boolean isClosed() { + return status.isClosed(); + } + + public boolean isMerged() { + return isClosed() && !isEmpty(mergeSha); + } + + public boolean isProposal() { + return Type.Proposal == type; + } + + public boolean isBug() { + return Type.Bug == type; + } + + public Date getLastUpdated() { + return updated == null ? created : updated; + } + + public boolean hasPatchsets() { + return getPatchsets().size() > 0; + } + + /** + * Returns true if multiple participants are involved in discussing a ticket. + * The ticket creator is excluded from this determination because a + * discussion requires more than one participant. + * + * @return true if this ticket has a discussion + */ + public boolean hasDiscussion() { + for (Change change : getComments()) { + if (!change.author.equals(createdBy)) { + return true; + } + } + return false; + } + + /** + * Returns the list of changes with comments. + * + * @return + */ + public List<Change> getComments() { + List<Change> list = new ArrayList<Change>(); + for (Change change : changes) { + if (change.hasComment()) { + list.add(change); + } + } + return list; + } + + /** + * Returns the list of participants for the ticket. + * + * @return the list of participants + */ + public List<String> getParticipants() { + Set<String> set = new LinkedHashSet<String>(); + for (Change change : changes) { + if (change.isParticipantChange()) { + set.add(change.author); + } + } + if (responsible != null && responsible.length() > 0) { + set.add(responsible); + } + return new ArrayList<String>(set); + } + + public boolean hasLabel(String label) { + return getLabels().contains(label); + } + + public List<String> getLabels() { + return getList(Field.labels); + } + + public boolean isResponsible(String username) { + return username.equals(responsible); + } + + public boolean isAuthor(String username) { + return username.equals(createdBy); + } + + public boolean isReviewer(String username) { + return getReviewers().contains(username); + } + + public List<String> getReviewers() { + return getList(Field.reviewers); + } + + public boolean isWatching(String username) { + return getWatchers().contains(username); + } + + public List<String> getWatchers() { + return getList(Field.watchers); + } + + public boolean isVoter(String username) { + return getVoters().contains(username); + } + + public List<String> getVoters() { + return getList(Field.voters); + } + + public List<String> getMentions() { + return getList(Field.mentions); + } + + protected List<String> getList(Field field) { + Set<String> set = new TreeSet<String>(); + for (Change change : changes) { + if (change.hasField(field)) { + String values = change.getString(field); + for (String value : values.split(",")) { + switch (value.charAt(0)) { + case '+': + set.add(value.substring(1)); + break; + case '-': + set.remove(value.substring(1)); + break; + default: + set.add(value); + } + } + } + } + if (!set.isEmpty()) { + return new ArrayList<String>(set); + } + return Collections.emptyList(); + } + + public Attachment getAttachment(String name) { + Attachment attachment = null; + for (Change change : changes) { + if (change.hasAttachments()) { + Attachment a = change.getAttachment(name); + if (a != null) { + attachment = a; + } + } + } + return attachment; + } + + public boolean hasAttachments() { + for (Change change : changes) { + if (change.hasAttachments()) { + return true; + } + } + return false; + } + + public List<Attachment> getAttachments() { + List<Attachment> list = new ArrayList<Attachment>(); + for (Change change : changes) { + if (change.hasAttachments()) { + list.addAll(change.attachments); + } + } + return list; + } + + public List<Patchset> getPatchsets() { + List<Patchset> list = new ArrayList<Patchset>(); + for (Change change : changes) { + if (change.patchset != null) { + list.add(change.patchset); + } + } + return list; + } + + public List<Patchset> getPatchsetRevisions(int number) { + List<Patchset> list = new ArrayList<Patchset>(); + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number) { + list.add(change.patchset); + } + } + } + return list; + } + + public Patchset getPatchset(String sha) { + for (Change change : changes) { + if (change.patchset != null) { + if (sha.equals(change.patchset.tip)) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getPatchset(int number, int rev) { + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number && rev == change.patchset.rev) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getCurrentPatchset() { + Patchset patchset = null; + for (Change change : changes) { + if (change.patchset != null) { + if (patchset == null) { + patchset = change.patchset; + } else if (patchset.compareTo(change.patchset) == 1) { + patchset = change.patchset; + } + } + } + return patchset; + } + + public boolean isCurrent(Patchset patchset) { + if (patchset == null) { + return false; + } + Patchset curr = getCurrentPatchset(); + if (curr == null) { + return false; + } + return curr.equals(patchset); + } + + public List<Change> getReviews(Patchset patchset) { + if (patchset == null) { + return Collections.emptyList(); + } + // collect the patchset reviews by author + // the last review by the author is the + // official review + Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>(); + for (Change change : changes) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + reviews.put(change.author, change); + } + } + } + return new ArrayList<Change>(reviews.values()); + } + + + public boolean isApproved(Patchset patchset) { + if (patchset == null) { + return false; + } + boolean approved = false; + boolean vetoed = false; + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.approved == change.review.score) { + approved = true; + } else if (Score.vetoed == change.review.score) { + vetoed = true; + } + } + } + } + return approved && !vetoed; + } + + public boolean isVetoed(Patchset patchset) { + if (patchset == null) { + return false; + } + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.vetoed == change.review.score) { + return true; + } + } + } + } + return false; + } + + public Review getReviewBy(String username) { + for (Change change : getReviews(getCurrentPatchset())) { + if (change.author.equals(username)) { + return change.review; + } + } + return null; + } + + public boolean isPatchsetAuthor(String username) { + for (Change change : changes) { + if (change.hasPatchset()) { + if (change.author.equals(username)) { + return true; + } + } + } + return false; + } + + public void applyChange(Change change) { + if (changes.size() == 0) { + // first change created the ticket + created = change.date; + createdBy = change.author; + status = Status.New; + } else if (created == null || change.date.after(created)) { + // track last ticket update + updated = change.date; + updatedBy = change.author; + } + + if (change.isMerge()) { + // identify merge patchsets + if (isEmpty(responsible)) { + responsible = change.author; + } + status = Status.Merged; + } + + if (change.hasFieldChanges()) { + for (Map.Entry<Field, String> entry : change.fields.entrySet()) { + Field field = entry.getKey(); + Object value = entry.getValue(); + switch (field) { + case type: + type = TicketModel.Type.fromObject(value, type); + break; + case status: + status = TicketModel.Status.fromObject(value, status); + break; + case title: + title = toString(value); + break; + case body: + body = toString(value); + break; + case topic: + topic = toString(value); + break; + case responsible: + responsible = toString(value); + break; + case milestone: + milestone = toString(value); + break; + case mergeTo: + mergeTo = toString(value); + break; + case mergeSha: + mergeSha = toString(value); + break; + default: + // unknown + break; + } + } + } + + // add the change to the ticket + changes.add(change); + } + + protected String toString(Object value) { + if (value == null) { + return null; + } + return value.toString(); + } + + public String toIndexableString() { + StringBuilder sb = new StringBuilder(); + if (!isEmpty(title)) { + sb.append(title).append('\n'); + } + if (!isEmpty(body)) { + sb.append(body).append('\n'); + } + for (Change change : changes) { + if (change.hasComment()) { + sb.append(change.comment.text); + sb.append('\n'); + } + } + return sb.toString(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("#"); + sb.append(number); + sb.append(": " + title + "\n"); + for (Change change : changes) { + sb.append(change); + sb.append('\n'); + } + return sb.toString(); + } + + @Override + public int compareTo(TicketModel o) { + return o.created.compareTo(created); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketModel) { + return number == ((TicketModel) o).number; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return (repository + number).hashCode(); + } + + /** + * Encapsulates a ticket change + */ + public static class Change implements Serializable, Comparable<Change> { + + private static final long serialVersionUID = 1L; + + public final Date date; + + public final String author; + + public Comment comment; + + public Map<Field, String> fields; + + public Set<Attachment> attachments; + + public Patchset patchset; + + public Review review; + + private transient String id; + + public Change(String author) { + this(author, new Date()); + } + + public Change(String author, Date date) { + this.date = date; + this.author = author; + } + + public boolean isStatusChange() { + return hasField(Field.status); + } + + public Status getStatus() { + Status state = Status.fromObject(getField(Field.status), null); + return state; + } + + public boolean isMerge() { + return hasField(Field.status) && hasField(Field.mergeSha); + } + + public boolean hasPatchset() { + return patchset != null; + } + + public boolean hasReview() { + return review != null; + } + + public boolean hasComment() { + return comment != null && !comment.isDeleted(); + } + + public Comment comment(String text) { + comment = new Comment(text); + comment.id = TicketModel.getSHA1(date.toString() + author + text); + + try { + Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Matcher m = mentions.matcher(text); + while (m.find()) { + String username = m.group(1); + plusList(Field.mentions, username); + } + } catch (Exception e) { + // ignore + } + return comment; + } + + public Review review(Patchset patchset, Score score, boolean addReviewer) { + if (addReviewer) { + plusList(Field.reviewers, author); + } + review = new Review(patchset.number, patchset.rev); + review.score = score; + return review; + } + + public boolean hasAttachments() { + return !TicketModel.isEmpty(attachments); + } + + public void addAttachment(Attachment attachment) { + if (attachments == null) { + attachments = new LinkedHashSet<Attachment>(); + } + attachments.add(attachment); + } + + public Attachment getAttachment(String name) { + if (attachments != null) { + for (Attachment attachment : attachments) { + if (attachment.name.equalsIgnoreCase(name)) { + return attachment; + } + } + } + return null; + } + + public boolean isParticipantChange() { + if (hasComment() + || hasReview() + || hasPatchset() + || hasAttachments()) { + return true; + } + + if (TicketModel.isEmpty(fields)) { + return false; + } + + // identify real ticket field changes + Map<Field, String> map = new HashMap<Field, String>(fields); + map.remove(Field.watchers); + map.remove(Field.voters); + return !map.isEmpty(); + } + + public boolean hasField(Field field) { + return !TicketModel.isEmpty(getString(field)); + } + + public boolean hasFieldChanges() { + return !TicketModel.isEmpty(fields); + } + + public String getField(Field field) { + if (fields != null) { + return fields.get(field); + } + return null; + } + + public void setField(Field field, Object value) { + if (fields == null) { + fields = new LinkedHashMap<Field, String>(); + } + if (value == null) { + fields.put(field, null); + } else if (Enum.class.isAssignableFrom(value.getClass())) { + fields.put(field, ((Enum<?>) value).name()); + } else { + fields.put(field, value.toString()); + } + } + + public void remove(Field field) { + if (fields != null) { + fields.remove(field); + } + } + + public String getString(Field field) { + String value = getField(field); + if (value == null) { + return null; + } + return value; + } + + public void watch(String... username) { + plusList(Field.watchers, username); + } + + public void unwatch(String... username) { + minusList(Field.watchers, username); + } + + public void vote(String... username) { + plusList(Field.voters, username); + } + + public void unvote(String... username) { + minusList(Field.voters, username); + } + + public void label(String... label) { + plusList(Field.labels, label); + } + + public void unlabel(String... label) { + minusList(Field.labels, label); + } + + protected void plusList(Field field, String... items) { + modList(field, "+", items); + } + + protected void minusList(Field field, String... items) { + modList(field, "-", items); + } + + private void modList(Field field, String prefix, String... items) { + List<String> list = new ArrayList<String>(); + for (String item : items) { + list.add(prefix + item); + } + setField(field, join(list, ",")); + } + + public String getId() { + if (id == null) { + id = getSHA1(Long.toHexString(date.getTime()) + author); + } + return id; + } + + @Override + public int compareTo(Change c) { + return date.compareTo(c.date); + } + + @Override + public int hashCode() { + return getId().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Change) { + return getId().equals(((Change) o).getId()); + } + return false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(RelativeDateFormatter.format(date)); + if (hasComment()) { + sb.append(" commented on by "); + } else if (hasPatchset()) { + sb.append(MessageFormat.format(" {0} uploaded by ", patchset)); + } else { + sb.append(" changed by "); + } + sb.append(author).append(" - "); + if (hasComment()) { + if (comment.isDeleted()) { + sb.append("(deleted) "); + } + sb.append(comment.text).append(" "); + } + + if (hasFieldChanges()) { + for (Map.Entry<Field, String> entry : fields.entrySet()) { + sb.append("\n "); + sb.append(entry.getKey().name()); + sb.append(':'); + sb.append(entry.getValue()); + } + } + return sb.toString(); + } + } + + /** + * Returns true if the string is null or empty. + * + * @param value + * @return true if string is null or empty + */ + static boolean isEmpty(String value) { + return value == null || value.trim().length() == 0; + } + + /** + * Returns true if the collection is null or empty + * + * @param collection + * @return + */ + static boolean isEmpty(Collection<?> collection) { + return collection == null || collection.size() == 0; + } + + /** + * Returns true if the map is null or empty + * + * @param map + * @return + */ + static boolean isEmpty(Map<?, ?> map) { + return map == null || map.size() == 0; + } + + /** + * Calculates the SHA1 of the string. + * + * @param text + * @return sha1 of the string + */ + static String getSHA1(String text) { + try { + byte[] bytes = text.getBytes("iso-8859-1"); + return getSHA1(bytes); + } catch (UnsupportedEncodingException u) { + throw new RuntimeException(u); + } + } + + /** + * Calculates the SHA1 of the byte array. + * + * @param bytes + * @return sha1 of the byte array + */ + static String getSHA1(byte[] bytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(bytes, 0, bytes.length); + byte[] digest = md.digest(); + return toHex(digest); + } catch (NoSuchAlgorithmException t) { + throw new RuntimeException(t); + } + } + + /** + * Returns the hex representation of the byte array. + * + * @param bytes + * @return byte array as hex string + */ + static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (int i = 0; i < bytes.length; i++) { + if ((bytes[i] & 0xff) < 0x10) { + sb.append('0'); + } + sb.append(Long.toString(bytes[i] & 0xff, 16)); + } + return sb.toString(); + } + + /** + * Join the list of strings into a single string with a space separator. + * + * @param values + * @return joined list + */ + static String join(Collection<String> values) { + return join(values, " "); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(String[] values, String separator) { + return join(Arrays.asList(values), separator); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(Collection<String> values, String separator) { + StringBuilder sb = new StringBuilder(); + for (String value : values) { + sb.append(value).append(separator); + } + if (sb.length() > 0) { + // truncate trailing separator + sb.setLength(sb.length() - separator.length()); + } + return sb.toString().trim(); + } + + + /** + * Produce a deep copy of the given object. Serializes the entire object to + * a byte array in memory. Recommended for relatively small objects. + */ + @SuppressWarnings("unchecked") + static <T> T copy(T original) { + T o = null; + try { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(byteOut); + oos.writeObject(original); + ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(byteIn); + try { + o = (T) ois.readObject(); + } catch (ClassNotFoundException cex) { + // actually can not happen in this instance + } + } catch (IOException iox) { + // doesn't seem likely to happen as these streams are in memory + throw new RuntimeException(iox); + } + return o; + } + + public static class Patchset implements Serializable, Comparable<Patchset> { + + private static final long serialVersionUID = 1L; + + public int number; + public int rev; + public String tip; + public String parent; + public String base; + public int insertions; + public int deletions; + public int commits; + public int added; + public PatchsetType type; + + public boolean isFF() { + return PatchsetType.FastForward == type; + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Patchset) { + return hashCode() == o.hashCode(); + } + return false; + } + + @Override + public int compareTo(Patchset p) { + if (number > p.number) { + return -1; + } else if (p.number > number) { + return 1; + } else { + // same patchset, different revision + if (rev > p.rev) { + return -1; + } else if (p.rev > rev) { + return 1; + } else { + // same patchset & revision + return 0; + } + } + } + + @Override + public String toString() { + return "patchset " + number + " revision " + rev; + } + } + + public static class Comment implements Serializable { + + private static final long serialVersionUID = 1L; + + public String text; + + public String id; + + public Boolean deleted; + + public CommentSource src; + + public String replyTo; + + Comment(String text) { + this.text = text; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public String toString() { + return text; + } + } + + public static class Attachment implements Serializable { + + private static final long serialVersionUID = 1L; + + public final String name; + public long size; + public byte[] content; + public Boolean deleted; + + public Attachment(String name) { + this.name = name; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Attachment) { + return name.equalsIgnoreCase(((Attachment) o).name); + } + return false; + } + + @Override + public String toString() { + return name; + } + } + + public static class Review implements Serializable { + + private static final long serialVersionUID = 1L; + + public final int patchset; + + public final int rev; + + public Score score; + + public Review(int patchset, int revision) { + this.patchset = patchset; + this.rev = revision; + } + + public boolean isReviewOf(Patchset p) { + return patchset == p.number && rev == p.rev; + } + + @Override + public String toString() { + return "review of patchset " + patchset + " rev " + rev + ":" + score; + } + } + + public static enum Score { + approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2); + + final int value; + + Score(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + } + + public static enum Field { + title, body, responsible, type, status, milestone, mergeSha, mergeTo, + topic, labels, watchers, reviewers, voters, mentions; + } + + public static enum Type { + Enhancement, Task, Bug, Proposal, Question; + + public static Type defaultType = Task; + + public static Type [] choices() { + return new Type [] { Enhancement, Task, Bug, Question }; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Type fromObject(Object o, Type defaultType) { + if (o instanceof Type) { + // cast and return + return (Type) o; + } else if (o instanceof String) { + // find by name + for (Type type : values()) { + String str = o.toString(); + if (type.name().equalsIgnoreCase(str) + || type.toString().equalsIgnoreCase(str)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultType; + } + } + + public static enum Status { + New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold; + + public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold }; + + public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold }; + + public static Status [] proposalWorkflow = { Open, Declined, On_Hold}; + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Status fromObject(Object o, Status defaultStatus) { + if (o instanceof Status) { + // cast and return + return (Status) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (Status state : values()) { + if (state.name().equalsIgnoreCase(name) + || state.toString().equalsIgnoreCase(name)) { + return state; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultStatus; + } + + public boolean isClosed() { + return ordinal() > Open.ordinal(); + } + } + + public static enum CommentSource { + Comment, Email + } + + public static enum PatchsetType { + Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend; + + public boolean isRewrite() { + return (this != FastForward) && (this != Proposal); + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', '+'); + } + + public static PatchsetType fromObject(Object o) { + if (o instanceof PatchsetType) { + // cast and return + return (PatchsetType) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (PatchsetType type : values()) { + if (type.name().equalsIgnoreCase(name) + || type.toString().equalsIgnoreCase(name)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return null; + } + } +} |