From c2188a840bc4153ae92112b04b2e06a90d3944aa Mon Sep 17 00:00:00 2001
From: Paul Martin <paul@paulsputer.com>
Date: Wed, 27 Apr 2016 18:58:06 -0400
Subject: [PATCH] Ticket Reference handling #1048

---
 src/main/java/com/gitblit/models/TicketModel.java |  428 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 413 insertions(+), 15 deletions(-)

diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java
index 1ff55dd..d534589 100644
--- a/src/main/java/com/gitblit/models/TicketModel.java
+++ b/src/main/java/com/gitblit/models/TicketModel.java
@@ -35,6 +35,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.regex.Matcher;
@@ -90,6 +91,10 @@
 
 	public Integer deletions;
 
+	public Priority priority;
+	
+	public Severity severity;
+	
 	/**
 	 * Builds an effective ticket from the collection of changes.  A change may
 	 * Add or Subtract information from a ticket, but the collection of changes
@@ -102,6 +107,31 @@
 		TicketModel ticket;
 		List<Change> effectiveChanges = new ArrayList<Change>();
 		Map<String, Change> comments = new HashMap<String, Change>();
+		Map<String, Change> references = new HashMap<String, Change>();
+		Map<Integer, Integer> latestRevisions = new HashMap<Integer, Integer>();
+		
+		int latestPatchsetNumber = -1;
+		
+		List<Integer> deletedPatchsets = new ArrayList<Integer>();
+		
+		for (Change change : changes) {
+			if (change.patchset != null) {
+				if (change.patchset.isDeleted()) {
+					deletedPatchsets.add(change.patchset.number);
+				} else {
+					Integer latestRev = latestRevisions.get(change.patchset.number);
+					
+					if (latestRev == null || change.patchset.rev > latestRev) {
+						latestRevisions.put(change.patchset.number, change.patchset.rev);
+					}
+					
+					if (change.patchset.number > latestPatchsetNumber) {
+						latestPatchsetNumber = change.patchset.number;
+					}	
+				}
+			}
+		}
+		
 		for (Change change : changes) {
 			if (change.comment != null) {
 				if (comments.containsKey(change.comment.id)) {
@@ -117,6 +147,31 @@
 					effectiveChanges.add(change);
 					comments.put(change.comment.id, change);
 				}
+			} else if (change.patchset != null) {
+				//All revisions of a deleted patchset are not displayed
+				if (!deletedPatchsets.contains(change.patchset.number)) {
+					
+					Integer latestRev = latestRevisions.get(change.patchset.number);
+					
+					if (    (change.patchset.number < latestPatchsetNumber) 
+						 && (change.patchset.rev == latestRev)) {
+						change.patchset.canDelete = true;
+					}
+					
+					effectiveChanges.add(change);
+				}
+			} else if (change.reference != null){
+				if (references.containsKey(change.reference.toString())) {
+					Change original = references.get(change.reference.toString());
+					Change clone = copy(original);
+					clone.reference.deleted = change.reference.deleted;
+					int idx = effectiveChanges.indexOf(original);
+					effectiveChanges.remove(original);
+					effectiveChanges.add(idx, clone);
+				} else {
+					effectiveChanges.add(change);
+					references.put(change.reference.toString(), change);
+				}
 			} else {
 				effectiveChanges.add(change);
 			}
@@ -125,9 +180,15 @@
 		// effective ticket
 		ticket = new TicketModel();
 		for (Change change : effectiveChanges) {
+			//Ensure deleted items are not included
 			if (!change.hasComment()) {
-				// ensure we do not include a deleted comment
 				change.comment = null;
+			}
+			if (!change.hasReference()) {
+				change.reference = null;
+			}
+			if (!change.hasPatchset()) {
+				change.patchset = null;
 			}
 			ticket.applyChange(change);
 		}
@@ -140,6 +201,8 @@
 		changes = new ArrayList<Change>();
 		status = Status.New;
 		type = Type.defaultType;
+		priority = Priority.defaultPriority;
+		severity = Severity.defaultSeverity;
 	}
 
 	public boolean isOpen() {
@@ -310,6 +373,15 @@
 		return false;
 	}
 
+	public boolean hasReferences() {
+		for (Change change : changes) {
+			if (change.hasReference()) {
+				return true;
+			}
+		}
+		return false;
+	}
+	
 	public List<Attachment> getAttachments() {
 		List<Attachment> list = new ArrayList<Attachment>();
 		for (Change change : changes) {
@@ -320,6 +392,16 @@
 		return list;
 	}
 
+	public List<Reference> getReferences() {
+		List<Reference> list = new ArrayList<Reference>();
+		for (Change change : changes) {
+			if (change.hasReference()) {
+				list.add(change.reference);
+			}
+		}
+		return list;
+	}
+	
 	public List<Patchset> getPatchsets() {
 		List<Patchset> list = new ArrayList<Patchset>();
 		for (Change change : changes) {
@@ -516,6 +598,12 @@
 				case mergeSha:
 					mergeSha = toString(value);
 					break;
+				case priority:
+					priority = TicketModel.Priority.fromObject(value, priority);
+					break;
+				case severity:
+					severity = TicketModel.Severity.fromObject(value, severity);
+					break;
 				default:
 					// unknown
 					break;
@@ -523,8 +611,12 @@
 			}
 		}
 
-		// add the change to the ticket
-		changes.add(change);
+		// add real changes to the ticket and ensure deleted changes are removed
+		if (change.isEmptyChange()) {
+			changes.remove(change);
+		} else {
+			changes.add(change);
+		}
 	}
 
 	protected String toString(Object value) {
@@ -595,6 +687,8 @@
 
 		public Comment comment;
 
+		public Reference reference;
+
 		public Map<Field, String> fields;
 
 		public Set<Attachment> attachments;
@@ -604,6 +698,10 @@
 		public Review review;
 
 		private transient String id;
+
+		//Once links have been made they become a reference on the target ticket
+		//The ticket service handles promoting links to references
+		public transient List<TicketLink> pendingLinks;
 
 		public Change(String author) {
 			this(author, new Date());
@@ -628,7 +726,7 @@
 		}
 
 		public boolean hasPatchset() {
-			return patchset != null;
+			return patchset != null && !patchset.isDeleted();
 		}
 
 		public boolean hasReview() {
@@ -636,13 +734,44 @@
 		}
 
 		public boolean hasComment() {
-			return comment != null && !comment.isDeleted();
+			return comment != null && !comment.isDeleted() && comment.text != null;
+		}
+		
+		public boolean hasReference() {
+			return reference != null && !reference.isDeleted();
+		}
+
+		public boolean hasPendingLinks() {
+			return pendingLinks != null && pendingLinks.size() > 0;
 		}
 
 		public Comment comment(String text) {
 			comment = new Comment(text);
 			comment.id = TicketModel.getSHA1(date.toString() + author + text);
 
+			// parse comment looking for ref #n
+			//TODO: Ideally set via settings
+			String x = "(?:ref|task|issue|bug)?[\\s-]*#(\\d+)";
+
+			try {
+				Pattern p = Pattern.compile(x, Pattern.CASE_INSENSITIVE);
+				Matcher m = p.matcher(text);
+				while (m.find()) {
+					String val = m.group(1);
+					long targetTicketId = Long.parseLong(val);
+					
+					if (targetTicketId > 0) {
+						if (pendingLinks == null) {
+							pendingLinks = new ArrayList<TicketLink>();
+						}
+						
+						pendingLinks.add(new TicketLink(targetTicketId, TicketAction.Comment));
+					}
+				}
+			} catch (Exception e) {
+				// ignore
+			}
+			
 			try {
 				Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
 				Matcher m = mentions.matcher(text);
@@ -656,6 +785,16 @@
 			return comment;
 		}
 
+		public Reference referenceCommit(String commitHash) {
+			reference = new Reference(commitHash);
+			return reference;
+		}
+
+		public Reference referenceTicket(long ticketId, String changeHash) {
+			reference = new Reference(ticketId, changeHash);
+			return reference;
+		}
+		
 		public Review review(Patchset patchset, Score score, boolean addReviewer) {
 			if (addReviewer) {
 				plusList(Field.reviewers, author);
@@ -785,7 +924,21 @@
 			for (String item : items) {
 				list.add(prefix + item);
 			}
-			setField(field, join(list, ","));
+			if (hasField(field)) {
+				String flat = getString(field);
+				if (isEmpty(flat)) {
+					// field is empty, use this list
+					setField(field, join(list, ","));
+				} else {
+					// merge this list into the existing field list
+					Set<String> set = new TreeSet<String>(Arrays.asList(flat.split(",")));
+					set.addAll(list);
+					setField(field, join(set, ","));
+				}
+			} else {
+				// does not have a list for this field
+				setField(field, join(list, ","));
+			}
 		}
 
 		public String getId() {
@@ -812,6 +965,17 @@
 			}
 			return false;
 		}
+		
+		/*
+		 * Identify if this is an empty change. i.e. only an author and date is defined.
+		 * This can occur when items have been deleted
+		 * @returns true if the change is empty
+		 */
+		private boolean isEmptyChange() {
+			return ((comment == null) && (reference == null) && 
+					(fields == null) && (attachments == null) && 
+					(patchset == null) && (review == null));
+		}
 
 		@Override
 		public String toString() {
@@ -821,6 +985,8 @@
 				sb.append(" commented on by ");
 			} else if (hasPatchset()) {
 				sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
+			} else if (hasReference()) {
+				sb.append(MessageFormat.format(" referenced in {0} by ", reference));
 			} else {
 				sb.append(" changed by ");
 			}
@@ -1006,8 +1172,14 @@
 		public int added;
 		public PatchsetType type;
 
+		public transient boolean canDelete = false;
+
 		public boolean isFF() {
 			return PatchsetType.FastForward == type;
+		}
+
+		public boolean isDeleted() {
+			return PatchsetType.Delete == type;
 		}
 
 		@Override
@@ -1075,6 +1247,114 @@
 			return text;
 		}
 	}
+	
+	
+	public static enum TicketAction {
+		Commit, Comment, Patchset, Close
+	}
+	
+	//Intentionally not serialized, links are persisted as "references"
+	public static class TicketLink {
+		public long targetTicketId;
+		public String hash;
+		public TicketAction action;
+		public boolean success;
+		public boolean isDelete;
+		
+		public TicketLink(long targetTicketId, TicketAction action) {
+			this.targetTicketId = targetTicketId;
+			this.action = action;
+			success = false;
+			isDelete = false;
+		}
+		
+		public TicketLink(long targetTicketId, TicketAction action, String hash) {
+			this.targetTicketId = targetTicketId;
+			this.action = action;
+			this.hash = hash;
+			success = false;
+			isDelete = false;
+		}
+	}
+	
+	public static enum ReferenceType {
+		Undefined, Commit, Ticket;
+	
+		@Override
+		public String toString() {
+			return name().toLowerCase().replace('_', ' ');
+		}
+		
+		public static ReferenceType fromObject(Object o, ReferenceType defaultType) {
+			if (o instanceof ReferenceType) {
+				// cast and return
+				return (ReferenceType) o;
+			} else if (o instanceof String) {
+				// find by name
+				for (ReferenceType 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 class Reference implements Serializable {
+	
+		private static final long serialVersionUID = 1L;
+		
+		public String hash;
+		public Long ticketId;
+		
+		public Boolean deleted;
+		
+		Reference(String commitHash) {
+			this.hash = commitHash;
+		}
+		
+		Reference(long ticketId, String changeHash) {
+			this.ticketId = ticketId;
+			this.hash = changeHash;
+		}
+		
+		public ReferenceType getSourceType(){
+			if (hash != null) {
+				if (ticketId != null) {
+					return ReferenceType.Ticket;
+				} else {
+					return ReferenceType.Commit;
+				}
+			}
+			
+			return ReferenceType.Undefined;
+		}
+		
+		public boolean isDeleted() {
+			return deleted != null && deleted;
+		}
+		
+		@Override
+		public String toString() {
+			switch (getSourceType()) {
+				case Commit: return hash;
+				case Ticket: return ticketId.toString() + "#" + hash;
+				default: {} break;
+			}
+			
+			return String.format("Unknown Reference Type");
+		}
+	}
 
 	public static class Attachment implements Serializable {
 
@@ -1138,7 +1418,8 @@
 	}
 
 	public static enum Score {
-		approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2);
+		approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(
+				-2);
 
 		final int value;
 
@@ -1154,20 +1435,29 @@
 		public String toString() {
 			return name().toLowerCase().replace('_', ' ');
 		}
+
+		public static Score fromScore(int score) {
+			for (Score s : values()) {
+				if (s.getValue() == score) {
+					return s;
+				}
+			}
+			throw new NoSuchElementException(String.valueOf(score));
+		}
 	}
 
 	public static enum Field {
 		title, body, responsible, type, status, milestone, mergeSha, mergeTo,
-		topic, labels, watchers, reviewers, voters, mentions;
+		topic, labels, watchers, reviewers, voters, mentions, priority, severity;
 	}
 
 	public static enum Type {
-		Enhancement, Task, Bug, Proposal, Question;
+		Enhancement, Task, Bug, Proposal, Question, Maintenance;
 
 		public static Type defaultType = Task;
 
 		public static Type [] choices() {
-			return new Type [] { Enhancement, Task, Bug, Question };
+			return new Type [] { Enhancement, Task, Bug, Question, Maintenance };
 		}
 
 		@Override
@@ -1201,13 +1491,15 @@
 	}
 
 	public static enum Status {
-		New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold;
+		New, Open, Closed, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required;
 
-		public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold };
+		public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
 
-		public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold };
+		public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
 
-		public static Status [] proposalWorkflow = { Open, Declined, On_Hold};
+		public static Status [] proposalWorkflow = { Open, Resolved, Declined, Abandoned, On_Hold, No_Change_Required };
+
+		public static Status [] milestoneWorkflow = { Open, Closed, Abandoned, On_Hold };
 
 		@Override
 		public String toString() {
@@ -1248,7 +1540,7 @@
 	}
 
 	public static enum PatchsetType {
-		Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend;
+		Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend, Delete;
 
 		public boolean isRewrite() {
 			return (this != FastForward) && (this != Proposal);
@@ -1283,4 +1575,110 @@
 			return null;
 		}
 	}
+
+	public static enum Priority {
+		Low(-1), Normal(0), High(1), Urgent(2);
+
+		public static Priority defaultPriority = Normal;
+
+		final int value;
+
+		Priority(int value) {
+			this.value = value;
+		}
+
+		public int getValue() {
+			return value;
+		}
+		
+		public static Priority [] choices() {
+			return new Priority [] { Urgent, High, Normal, Low };
+		}
+
+		@Override
+		public String toString() {
+			return name().toLowerCase().replace('_', ' ');
+		}
+
+		public static Priority fromObject(Object o, Priority defaultPriority) {
+			if (o instanceof Priority) {
+				// cast and return
+				return (Priority) o;
+			} else if (o instanceof String) {
+				// find by name
+				for (Priority priority : values()) {
+					String str = o.toString();
+					if (priority.name().equalsIgnoreCase(str)
+							|| priority.toString().equalsIgnoreCase(str)) {
+						return priority;
+					}
+				}
+			} else if (o instanceof Number) {
+
+				switch (((Number) o).intValue()) {
+					case -1: return Priority.Low;
+					case 0:  return Priority.Normal;
+					case 1:  return Priority.High;
+					case 2:  return Priority.Urgent;
+					default: return Priority.Normal;
+				}
+			}
+
+			return defaultPriority;
+		}
+	}
+	
+	public static enum Severity {
+		Unrated(-1), Negligible(1), Minor(2), Serious(3), Critical(4), Catastrophic(5);
+
+		public static Severity defaultSeverity = Unrated;
+		
+		final int value;
+		
+		Severity(int value) {
+			this.value = value;
+		}
+
+		public int getValue() {
+			return value;
+		}
+		
+		public static Severity [] choices() {
+			return new Severity [] { Unrated, Negligible, Minor, Serious, Critical, Catastrophic };
+		}
+
+		@Override
+		public String toString() {
+			return name().toLowerCase().replace('_', ' ');
+		}
+		
+		public static Severity fromObject(Object o, Severity defaultSeverity) {
+			if (o instanceof Severity) {
+				// cast and return
+				return (Severity) o;
+			} else if (o instanceof String) {
+				// find by name
+				for (Severity severity : values()) {
+					String str = o.toString();
+					if (severity.name().equalsIgnoreCase(str)
+							|| severity.toString().equalsIgnoreCase(str)) {
+						return severity;
+					}
+				}
+			} else if (o instanceof Number) {
+				
+				switch (((Number) o).intValue()) {
+					case -1: return Severity.Unrated;
+					case 1:  return Severity.Negligible;
+					case 2:  return Severity.Minor;
+					case 3:  return Severity.Serious;
+					case 4:  return Severity.Critical;
+					case 5:  return Severity.Catastrophic;
+					default: return Severity.Unrated;
+				}
+			}
+
+			return defaultSeverity;
+		}
+	}
 }

--
Gitblit v1.9.1