Only handle one event (the most interesting one) in a bundled set.
1 files changed, 134 insertions(+), 95 deletions(-)

M googlechat.groovy
M googlechat.groovy +134 -95
@@ 23,6 23,10 @@ import com.atlassian.jira.component.Comp
 import com.atlassian.jira.workflow.WorkflowManager
 import com.atlassian.jira.issue.Issue
 import com.atlassian.jira.event.issue.*
+import com.atlassian.jira.event.issue.DelegatingJiraIssueEvent
+import com.atlassian.jira.event.issue.IssueEvent
+import com.atlassian.jira.event.issue.IssueEventBundle
+import com.atlassian.jira.event.type.EventType
 
 import org.ofbiz.core.entity.GenericValue
 

          
@@ 123,14 127,66 @@ def messageWebhook(String webhookUrl, Ch
 
 /** Classes implementing a toJSON() method, generating Hangout Chat message format (https://developers.google.com/hangouts/chat/reference/message-formats/).
  */
-interface ChatMessage {
-	String toJSON();
+abstract class ChatMessage {
+	abstract String toJSON();
+	abstract String keyValue(key, value)
+	abstract String changeLog();
+	abstract String reformat(String text);
+	protected List<String> changeLogParts() {
+		if (event?.changeLog) {
+			// "changeitems: ${event.changeLog.getRelated('ChildChangeItem').getClass()}";
+			return event.changeLog.getRelated("ChildChangeItem").collect {
+				def ci = it as GenericValue;
+				def field = ci.getString("field");
+				//if (field == "status") return "";
+				def oldval = ci.getString("oldstring");
+				def newval = ci.getString("newstring");
+				if (field == "WorklogId") { 
+					// worklog ID is weird. Only the 'old' value is set
+					keyValue(field, oldval);
+				} else if (field == "description") { 
+					keyValue(field, reformat(newval)); // Don't print the old description, it's too confusing
+				} else if (field == "timespent" || field == "timeestimate" || field == "timeoriginalestimate") { 
+					log "We're timeformatting ${newval}"
+					log "We're timeformatting ${oldval}"
+					keyValue(field, timeFormat(newval) + (oldval ? " (was: ${timeFormat(oldval)})" : ""));
+				} else {
+					keyValue(field, newval + (oldval ? " (was: ${oldval})" : ""));
+				}
+			}
+		}
+	}
+	/* Format seconds as days, hours and minutes. */
+	protected String formatSecs(int secs) {
+		PeriodFormatter formatter = new PeriodFormatterBuilder()
+		     .appendDays()
+		     .appendSuffix("d ")
+		     .appendHours()
+		     .appendSuffix("h ")
+		     .appendMinutes()
+		     .appendSuffix("m ")
+		     .toFormatter();
+		// https://stackoverflow.com/questions/22420335/convert-joda-time-seconds-to-formatted-time-string
+		String formatted = formatter.print(Period.seconds(secs).normalizedStandard());
+		return formatted;
+	}
+
+	/* Given a string allegedly representing seconds, returns a nice representation in days/hours/minutes. */
+	protected timeFormat(String secsStr) {
+		//https://stackoverflow.com/questions/1713481/groovy-string-to-int
+		if (secsStr?.isInteger()) {
+			int secs = secsStr as Integer;
+			//log "Parsed ${secsStr} into ${secs} seconds, formatted to ${formatSecs(secs)}";
+			return formatSecs(secs);
+		} else return secsStr;
+	}
+
 }
 
 /** 
  * Create a GChat 'Simple Text' formatted message, converting from Jira wiki to GChat wiki where possible.
  */
-class SimpleChatMessage implements ChatMessage {
+class SimpleChatMessage extends ChatMessage {
 	Issue issue;
 	IssueEvent event;
 

          
@@ 161,14 217,7 @@ class SimpleChatMessage implements ChatM
 		} else { null }
 	}
 
-	String textPara(key, value)
-	{
-		if (value != null) {
-			"""${value}"""
-		} else { null }
-	}
-
-	String reformat(String text) {
+	public String reformat(String text) {
 		text?.
 			replaceAll(~"\\{quote\\}\\r?", '```')?.
 			replaceAll(~"\\{noformat\\}\\r?", '```')?.

          
@@ 176,74 225,13 @@ class SimpleChatMessage implements ChatM
 			replaceAll(~"\\{color[#:0-9a-f]*\\}", "")?.
 			replaceAll(~"h[1-6]\\. (.*)", '*$1*')?.
 			replaceAll(~'[\n\r]{4}', "\n")?.
-			replaceAll(~'\\[([^|]+)\\|(http[^]]+)\\]', '<$2|$1>')?.
+			replaceAll(~'\\[([^]|]+?)\\|(http[^]]+?)\\]', '<$2|$1>')?.
 			replaceAll(~'\\{\\{(.*?)\\}\\}', '`$1`')?.
 			replaceAll(~'[A-Z]+-[0-9]+', "<${baseUrl}/browse/"+'$0|$0>');
 	}
 
-	/* Format seconds as days, hours and minutes. */
-	public String formatSecs(int secs) {
-		PeriodFormatter formatter = new PeriodFormatterBuilder()
-		     .appendDays()
-		     .appendSuffix("d ")
-		     .appendHours()
-		     .appendSuffix("h ")
-		     .appendMinutes()
-		     .appendSuffix("m ")
-		     .toFormatter();
-		// https://stackoverflow.com/questions/22420335/convert-joda-time-seconds-to-formatted-time-string
-		String formatted = formatter.print(Period.seconds(secs).normalizedStandard());
-		return formatted;
-	}
-
-	/* Given a string allegedly representing seconds, returns a nice representation in days/hours/minutes. */
-	String timeFormat(String secsStr) {
-		//https://stackoverflow.com/questions/1713481/groovy-string-to-int
-		if (secsStr?.isInteger()) {
-			int secs = secsStr as Integer;
-			//log "Parsed ${secsStr} into ${secs} seconds, formatted to ${formatSecs(secs)}";
-			return formatSecs(secs);
-		} else return secsStr;
-	}
-
-	String changeLog() {
-		if (event?.changeLog) {
-			// "changeitems: ${event.changeLog.getRelated('ChildChangeItem').getClass()}";
-			return  event.changeLog.getRelated("ChildChangeItem").collect {
-				def ci = it as GenericValue;
-				def field = ci.getString("field");
-				//if (field == "status") return "";
-				def oldval = ci.getString("oldstring");
-				def newval = ci.getString("newstring");
-				if (field == "WorklogId") { 
-					// worklog ID is weird. Only the 'old' value is set
-					keyValue(field, oldval);
-				} else if (field == "description") { 
-					keyValue(field, reformat(newval)); // Don't print the old description, it's too confusing
-				} else if (field == "timespent" || field == "timeestimate" || field == "timeoriginalestimate") { 
-					log "We're timeformatting ${newval}"
-					log "We're timeformatting ${oldval}"
-					keyValue(field, timeFormat(newval) + (oldval ? " (was: ${timeFormat(oldval)})" : ""));
-				} else {
-					keyValue(field, newval + (oldval ? " (was: ${oldval})" : ""));
-				}
-			}
-			.join("\n")
-		}
-	}
-
-	String comment() {
-		if (event.comment) {
-			def formattedComment = reformat(event.comment?.body);
-			if (event?.changeLog) { 
-				return '`Comment`: ' + formattedContent;
-			} else 
-				return formattedComment;
-		}
-	}
-
 	String header() {
-		event.user?.displayName + " " + eventType.
+		"◆  " + event.user?.displayName + " " + eventType.
 			replaceAll('Commented', 'commented on').
 			replaceAll('Issue Commented', 'commented on').
 			replaceAll("Generic Event", "updated").

          
@@ 254,16 242,41 @@ class SimpleChatMessage implements ChatM
 			" " + (issue.assignee ? "${issue.assignee.displayName}'s" : "unassigned") +
 			" " + issue.issueType?.name + 
 			" " + "<${baseUrl}/browse/${issue.key}|${issue.key} - ${issue.summary}> " + (issue.resolution ? "(${issue.status.name} / ${issue.resolution?.name})" : "") + 
-			":\n"
+			" ◆ \n"
+	}
+
+	String body() {
+		// Only print the description if it's an 'Issue Created' event. In all other cases, the description
+		// will be printed (if changed) as part of the changelog
+		def Long eventTypeID = event.getEventTypeId();
+		if (eventTypeID == 1) { 
+			return reformat(issue.description);
+		}
+	}
+
+	String changeLog() {
+		changeLogParts()?.join("\n");
+	}
+
+	String comment() {
+		if (event.comment) {
+			def formattedComment = reformat(event.comment?.body);
+			if (event?.changeLog) { 
+				return '`Comment` ' + formattedComment;
+			} else 
+				return formattedComment;
+		}
 	}
 
 	String toJSON() 
 	{
 		def textField = header() + [
-			changeLog()
+			body()
+			, changeLog()
 			, comment()
 			].findResults { it // keyValue() returns null for unset fields.
-			}.join("\n") + "\n\n"; // The newlines are to visually separate bot comments, which otherwise blend together.
+			}.join("\n")
+			//+ "\n\n"; // The newlines are to visually separate bot comments, which otherwise blend together.
 		return '{ "text": "' + textField + '" }'; 
 	}
 }

          
@@ 271,7 284,7 @@ class SimpleChatMessage implements ChatM
 /** Create a Google CardChatMessage JSON representation of an event. 
  * Note that cards clip the horizontal space, so in practice the SimpleChatMessage representation works better.
  */
-class CardChatMessage implements ChatMessage {
+class CardChatMessage extends ChatMessage {
 	Issue issue;
 	IssueEvent event;
 

          
@@ 294,7 307,10 @@ class CardChatMessage implements ChatMes
 		return ComponentAccessor.eventTypeManager.getEventType(event.getEventTypeId()).getName();
 	}
 
-
+	public String reformat(String text) {
+		// Card formatting does not support enough HTML to emulate block text for {code} or {quote}. 
+		text
+	}
 	String keyValue(key, value)
 	{
 		if (value != null) {

          
@@ 308,18 324,6 @@ class CardChatMessage implements ChatMes
 		} else { null }
 	}
 
-	String textPara(key, value)
-	{
-		if (value != null) {
-			"""
-			{
-				"textParagraph": {
-					"text": "${value}"
-				}
-			}"""
-		} else { null }
-	}
-
 	String changeLog() {
 		if (event?.changeLog) {
 			// "changeitems: ${event.changeLog.getRelated('ChildChangeItem').getClass()}";

          
@@ 385,9 389,44 @@ class CardChatMessage implements ChatMes
 	}
 }
 
+/** Returns true if this event is the most interesting in a set of events fired simultaneously (e.g. updated + assigned).
+ * See Adaptavist docs on event bundling at https://scriptrunner.adaptavist.com/latest/jira/listeners.html
+ */
+def boolean isMostInterestingEvent() {
+	def getIncludedEvents = {
+		bundle.events.findResults { includedEvent ->
+			includedEvent instanceof DelegatingJiraIssueEvent ? includedEvent.asIssueEvent() :  null
+		} as List<IssueEvent>
+	}
+
+	def eventWeight = { IssueEvent e ->
+		switch (e.eventTypeId) {
+			case 1:		10; break; // Created
+			case 2:		9; break; // Updated
+			case 3:		7; break; // Assigned
+			case 4..5:	10; break // Resolved, Closed
+			case 6:		6; break 	// Commented
+			case 7..12:	10; break // Reopened, Deleted, ...
+			case 13:	8; break; // Generic event
+			default:	10; break;
+		}
+	}
+	
+	def sorted = getIncludedEvents().sort { a, b -> 
+		log "Event a ${a.eventTypeId} has weight ${eventWeight(a)}";
+		log "Event b ${b.eventTypeId} has weight ${eventWeight(b)}";
+		eventWeight(a) < eventWeight(b) ? 1 : -1;
+	}
+
+	log "Event IDs: ${sorted.collect{ it.eventTypeId }.join(', ')}";
+	log "First event (of ${sorted.size}): ${sorted.first()?.eventTypeId}";
+
+	return event.is(sorted.first());
+}
+
 def webhookUrl = getWebhookUrl(event.issue, WEBHOOK_CHAT_CUSTOMFIELD_ID);
-if (webhookUrl) {
-	//CardChatMessage card = new CardChatMessage(event);
-	ChatMessage msg = new SimpleChatMessage(event);
-	messageWebhook(webhookUrl, msg);
+if (webhookUrl && isMostInterestingEvent()) {
+		//CardChatMessage msg = new CardChatMessage(event);
+		ChatMessage msg = new SimpleChatMessage(event);
+		messageWebhook(webhookUrl, msg);
 }