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