Docs and cleanups
2 files changed, 54 insertions(+), 64 deletions(-)

M README.md
M googlechat.groovy
M README.md +3 -1
@@ 4,7 4,7 @@ This [ScriptRunner for Jira](https://scr
 
 ![](jira-gchat-integration.png)
 
-The chat room is notified by webhook, and in this implementation, the webhook is read per-issue from a custom field. This means that chat notification needs to be explicitly configured per-issue. Feel free to modify the code (`getWebhookUrl`) to just return a static webhook URL for all issues.
+The chat room is notified by webhook. In this implementation the webhook is stored per-issue in a custom field. This means that chat notification needs to be explicitly configured per-issue. Feel free to modify the code (`getWebhookUrl`) to just return a static webhook URL for all issues.
 
 ## Installation
 

          
@@ 27,3 27,5 @@ If it doesn't work, check the ScriptRunn
 I initially was posting GChat messages using the [card format](https://developers.google.com/hangouts/chat/reference/message-formats/cards), which seems (at first) ideal for key-value pairs. However in practice the card format doesn't work: horizontal width is clipped to about 5cm, and there is no way to format blockquoted text. So I switched to the [basic format](https://developers.google.com/hangouts/chat/reference/message-formats/basic), and have converted Jira wiki markup to GChat markup as far as possible.
 
 As for future work, it would be nice if we could @mention people like the assignee. That would require looking up GChat user IDs via an authenticated REST call, then persisting the username -> GChatUserID mappings, perhaps using [ActiveObjects](https://community.atlassian.com/t5/Marketplace-Apps-Integrations/Active-Objects-in-a-ScriptRunner-plugin/qaq-p/624518), so that it is cached across events.
+
+Another nice enhancement would be to set the webhook per project, rather than per issue. This would allow the 'Issue Created' events to be reported. This could be implemented using the [Enhanced Project Properties](https://marketplace.atlassian.com/apps/1217709/enhanced-project-properties?hosting=server&tab=overview) plugin (see [groovy code example](https://bitbucket.org/scmenthusiast/scmenthusiast-utilities/src/master/project-properties/groovy/project_properties_fetch.groovy)).

          
M googlechat.groovy +51 -63
@@ 1,7 1,7 @@ 
 /** 
  * ScriptRunner for Jira listener that messages a Google Chat group of an event, via a webhook whose URL is embedded as a custom field on issues.
  * 
- * jeff@redradishtech.com,  20 June 2019
+ * jeff@redradishtech.com,  26 June 2019
  * Apache license 2.0
  */
 

          
@@ 110,10 110,23 @@ def messageWebhook(String webhookUrl, Ch
 /** Represents an abstract GChat message. Concrete implementations should pick a representation (https://developers.google.com/hangouts/chat/reference/message-formats/).
  */
 abstract class ChatMessage {
-	abstract String toJSON();
-	abstract String keyValue(key, value)
-	abstract String changeLog();
-	abstract String reformat(String text);
+	protected Issue issue;
+	protected IssueEvent event;
+
+	ChatMessage(event) {
+		this.event = event;
+		this.issue = event.issue;
+	}
+
+	abstract public String toJSON();
+	/* JSON representation of a key:value pair. */
+	abstract protected String keyValue(key, value)
+	/* JSON representation of a changelog. Implementations should just merge changeLogParts() output as appropriate. */
+	abstract protected String changeLog();
+	/** Convert from Jira wiki format to GChat format. */
+	abstract protected String reformat(String text);
+
+	/** Utility method used by changeLog(). Gets key:value pairs for all changelog items in this event. */
 	protected List<String> changeLogParts() {
 		if (event?.changeLog) {
 			// "changeitems: ${event.changeLog.getRelated('ChildChangeItem').getClass()}";

          
@@ 130,8 143,8 @@ abstract class ChatMessage {
 				} 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}"
+					//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})" : ""));

          
@@ 139,6 152,7 @@ abstract class ChatMessage {
 			}.findResults { it } // Eliminate nulls (worklogId)
 		}
 	}
+
 	/* Format seconds as days, hours and minutes. */
 	protected String formatSecs(int secs) {
 		PeriodFormatter formatter = new PeriodFormatterBuilder()

          
@@ 164,32 178,29 @@ abstract class ChatMessage {
 		} else return secsStr;
 	}
 
+
+	String getBaseUrl() {
+		// https://community.atlassian.com/t5/Answers-Developer-Questions/How-do-I-programatically-find-JIRA-s-base-URL/qaq-p/519640
+		return ComponentAccessor.getApplicationProperties().getString("jira.baseurl");
+	}
+
+	String getEventType() {
+		def Long eventTypeID = event.getEventTypeId();
+		return ComponentAccessor.eventTypeManager.getEventType(event.getEventTypeId()).getName();
+	}
+
+	void log(msg) {
+		System.out.println(msg);
+	}
 }
 
 /** 
  * Create a GChat 'Simple Text' formatted message, converting from Jira wiki to GChat wiki where possible.
  */
 class SimpleChatMessage extends ChatMessage {
-	Issue issue;
-	IssueEvent event;
 
 	SimpleChatMessage(event) {
-		this.event = event;
-		this.issue = event.issue;
-	}
-
-	def log(msg) {
-		System.out.println(msg);
-	}
-
-	def getBaseUrl() {
-		// https://community.atlassian.com/t5/Answers-Developer-Questions/How-do-I-programatically-find-JIRA-s-base-URL/qaq-p/519640
-		return ComponentAccessor.getApplicationProperties().getString("jira.baseurl");
-	}
-
-	def getEventType() {
-		def Long eventTypeID = event.getEventTypeId();
-		return ComponentAccessor.eventTypeManager.getEventType(event.getEventTypeId()).getName();
+		super(event);
 	}
 
 	String keyValue(key, value)

          
@@ 200,7 211,7 @@ class SimpleChatMessage extends ChatMess
 	}
 
 	/** Convert from Jira wiki format to GChat format. */
-	public String reformat(String text) {
+	String reformat(String text) {
 		text?.
 			replaceAll(~"\\{quote\\}\\r?", '```')?.
 			replaceAll(~"\\{noformat\\}\\r?", '```')?.

          
@@ 269,30 280,15 @@ class SimpleChatMessage extends ChatMess
  * Note that cards clip the horizontal space, so in practice the SimpleChatMessage representation works better.
  */
 class CardChatMessage extends ChatMessage {
-	Issue issue;
-	IssueEvent event;
 
 	CardChatMessage(event) {
-		this.event = event;
-		this.issue = event.issue;
-	}
-
-	def log(msg) {
-		System.out.println(msg);
-	}
-
-	def getBaseUrl() {
-		// https://community.atlassian.com/t5/Answers-Developer-Questions/How-do-I-programatically-find-JIRA-s-base-URL/qaq-p/519640
-		return ComponentAccessor.getApplicationProperties().getString("jira.baseurl");
-	}
-
-	def getEventType() {
-		def Long eventTypeID = event.getEventTypeId();
-		return ComponentAccessor.eventTypeManager.getEventType(event.getEventTypeId()).getName();
+		super(event);
 	}
 
 	public String reformat(String text) {
-		// Card formatting does not support enough HTML to emulate block text for {code} or {quote}. 
+		// FIXME: Replace '[links|http://...]' with '<a href="http://...">links</a>', '*foo*' with '<b>foo</b>' and so forth. 
+		// I can't be too bothered, because card formatting does not support enough HTML to emulate block text for {code} or {quote}. 
+		// 
 		text
 	}
 	String keyValue(key, value)

          
@@ 309,20 305,7 @@ class CardChatMessage extends ChatMessag
 	}
 
 	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");
-				keyValue(field, newval + (oldval ? " (was: ${oldval})" : ""));
-			}
-			.join(", ")
-		} else {
-			return ""
-		}
+		changeLogParts()?.join(", ");
 	}
 
 	String toJSON() 

          
@@ 384,6 367,11 @@ def boolean isMostInterestingEvent() {
 	}
 
 	def eventWeight = { IssueEvent e ->
+		// In order of decreasing priority: <Most events>, Updated, Generic Event, Assigned, Commented.
+		// This means we'll only see 'Issue Commented' if the comment was the only event triggered.
+		// A reassign + comment will fire as 'Issue Assigned'.
+		// Editing an issue will fire 'Issue Updated' even if the issue is assigned and commented on.
+		// Most transitions that involve edits to first (incl. assignee) will trigger as that event's name.
 		switch (e.eventTypeId) {
 			case 1:		10; break; // Created
 			case 2:		9; break; // Updated

          
@@ 397,13 385,13 @@ def boolean isMostInterestingEvent() {
 	}
 	
 	def sorted = getIncludedEvents().sort { a, b -> 
-		log "Event a ${a.eventTypeId} has weight ${eventWeight(a)}";
-		log "Event b ${b.eventTypeId} has weight ${eventWeight(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}";
+	//log "Event IDs: ${sorted.collect{ it.eventTypeId }.join(', ')}";
+	//log "First event (of ${sorted.size}): ${sorted.first()?.eventTypeId}";
 
 	return event.is(sorted.first());
 }