import com.atlassian.jira.component.ComponentAccessor import com.atlassian.jira.project.version.Version import com.exalate.api.domain.connection.IConnection import com.exalate.api.domain.hubobject.v1_2.IHubVersion import com.exalate.api.domain.twintrace.ITwinTrace import com.exalate.api.exception.IssueTrackerException import com.exalate.api.persistence.twintrace.ITwinTraceRepository import com.exalate.basic.domain.hubobject.v1.BasicHubIssue import com.exalate.basic.domain.hubobject.v1.BasicHubVersion import com.exalate.node.hubobject.v1_3.NodeHelper import com.exalate.services.utils.FutureUtils import groovy.json.JsonOutput import groovy.json.JsonSlurper import scala.collection.immutable.Seq /** Usage: Add the snippet below to the end of your "Outgoing sync": Versions.send() // listenToVersionEvents = false -------------------------------- Add the snippet below to the end of your "Incoming sync": Versions.receive() -------------------------------- * */ class Versions { static getStorage(IConnection connection) { MappingStorageSupplier.get("com.exalate.scripts.Versions.v0.connections.${connection.getID()}".toString()) } static getContext(){ def context = null if (com.exalate.processor.jira.JiraCreateReplicaProcessor.dataFilterContext != null && com.exalate.processor.jira.JiraCreateReplicaProcessor.dataFilterContext.get() == true) { context = com.exalate.processor.jira.JiraCreateReplicaProcessor.threadLocalContext.get() } else if (com.exalate.processor.jira.JiraCreateIssueProcessor.createProcessorContext != null && com.exalate.processor.jira.JiraCreateIssueProcessor.createProcessorContext.get() == true) { context = com.exalate.processor.jira.JiraCreateIssueProcessor.threadLocalContext.get() } else if (com.exalate.processor.jira.JiraChangeIssueProcessor.changeProcessorContext != null && com.exalate.processor.jira.JiraChangeIssueProcessor.changeProcessorContext.get() == true) { context = com.exalate.processor.jira.JiraChangeIssueProcessor.threadLocalContext.get() } else { throw new com.exalate.api.exception.IssueTrackerException(""" No context for executing external script Versions.groovy. Please contact Exalate Support.""".toString()) } return context } static BasicHubIssue send(boolean listenToVersionEvents = false){ def context = getContext() final def replica = context.replica final def issue = context.issue final def connection = context.connection try{ if (listenToVersionEvents) { def connectionId = connection.getID() def connectionName = connection.name ExalateVersionEventListener.listen(connectionId, connectionName) } else { ExalateVersionEventListener.unlisten(connection.getID()) } } catch(ignore){ //No such property: listenerInvokers for class: com.atlassian.event.internal.LockFreeEventPublisher } replica.fixVersions = issue.fixVersions replica.affectedVersions = issue.affectedVersions replica } static BasicHubIssue receive(){ def context = getContext() final def replica = context.replica final def issue = context.issue final def connection = context.connection final def nodeHelper = context.nodeHelper issue.fixVersions = receive(replica.fixVersions, issue.project?.key ?: issue.projectKey, connection, nodeHelper) as Set issue.affectedVersions = receive(replica.affectedVersions, issue.project?.key ?: issue.projectKey, connection, nodeHelper) as Set issue } static List receive(Set remoteVersions, String localProjectKey, IConnection connection, NodeHelper nodeHelper) { receive(true, remoteVersions, localProjectKey, connection, nodeHelper) } static List receive(boolean matchByName, Set remoteVersions, String localProjectKey, IConnection connection, NodeHelper nodeHelper) { def vm = com.atlassian.jira.component.ComponentAccessor.versionManager def pm = com.atlassian.jira.component.ComponentAccessor.projectManager try { def p = pm.getProjectObjByKey(localProjectKey) if (p == null) { throw new com.exalate.api.exception.IssueTrackerException("Contact Exalate Support - for some reason the project `$localProjectKey` is not found during remoteVersion sync for remote remoteVersions `${remoteVersions.collect { remoteVersion -> ["name":remoteVersion.name, "id":remoteVersion.id, "projectKey":remoteVersion.projectKey] } }`".toString()) } def localExProject = nodeHelper.getProject(localProjectKey) def storage = getStorage(connection) // SAAB-16 for now just create / update versions for remote versions (don't update, since this could result in bulk issue update) remoteVersions.collect { remoteVersion -> try { def createVersion = { def localJiraV = null try { localJiraV = vm.createVersion( //String name, remoteVersion.name, // Date startDate, remoteVersion.startDate, // Date releaseDate, remoteVersion.releaseDate, // String description, remoteVersion.description, // Long projectId, p.id, // Long scheduleAfterVersion, null, // boolean released remoteVersion.released ) } catch (MissingMethodException ignore) { // createVersion(String name, Date startDate, Date releaseDate, String description, Long projectId, Long scheduleAfterVersion) localJiraV = vm.createVersion( //String name, remoteVersion.name, // Date startDate, remoteVersion.startDate, // Date releaseDate, remoteVersion.releaseDate, // String description, remoteVersion.description, // Long projectId, p.id, // Long scheduleAfterVersion, null ) } storage.create(localJiraV.id as String, remoteVersion.id as String) if (remoteVersion.archived) { vm.archiveVersion(localJiraV, remoteVersion.archived) } // Log sucessful creation of select list value // ProgressLog.logVersionChange("CREATE VERSION", localProjectKey, localJiraV.name) // nodeHelper.nodeHubObjectConverter.getHubVersion(localJiraV) nodeHelper.getVersion(remoteVersion.name, localExProject) as BasicHubVersion } def updateVersion = { Version localJiraV -> def isNameDiff = remoteVersion.name != localJiraV.name def isDescriptionDiff = remoteVersion.description != localJiraV.description def isStartDateDiff = remoteVersion.startDate != localJiraV.startDate def isReleasedDiff = remoteVersion.released != localJiraV.released def isReleaseDateDiff = remoteVersion.releaseDate != localJiraV.releaseDate def isArchivedDiff = remoteVersion.archived != localJiraV.archived if (!(isNameDiff || isDescriptionDiff || isStartDateDiff || isReleasedDiff || isReleaseDateDiff || isArchivedDiff)) { // don't update the version return } def _VersionBuilderImpl = null try { _VersionBuilderImpl = com.atlassian.jira.component.ComponentAccessor.classLoader.loadClass("com.atlassian.jira.bc.project.version.VersionBuilderImpl") } catch (Exception ignore) { _VersionBuilderImpl = null } if (_VersionBuilderImpl) { def vb = _VersionBuilderImpl.newInstance([localJiraV] as Object[]) if (isNameDiff) { vb = vb.name(remoteVersion.name) } if (isDescriptionDiff) { vb = vb.description(remoteVersion.description) } if (isStartDateDiff) { vb = vb.startDate(remoteVersion.startDate) } if (isReleasedDiff || isReleaseDateDiff) { vb = vb.released(remoteVersion.released) vb = vb.releaseDate(remoteVersion.releaseDate) } if (isArchivedDiff) { vb = vb.archived(remoteVersion.archived) } vm.update(vb.build()) } else { //Jira 6 def _VersionBuilder = null try { _VersionBuilder = com.atlassian.jira.component.ComponentAccessor.classLoader.loadClass("com.atlassian.jira.bc.project.version.VersionService\$VersionBuilder") } catch (Exception ignore) { //... } if(_VersionBuilder) { def vb = _VersionBuilder.newInstance([localJiraV] as Object[]) if (isNameDiff) { vb = vb.name(remoteVersion.name) } if (isDescriptionDiff) { vb = vb.description(remoteVersion.description) } if (isStartDateDiff) { vb = vb.startDate(remoteVersion.startDate) } if (isReleaseDateDiff) { vb = vb.releaseDate(remoteVersion.releaseDate) } if (isArchivedDiff) { vb = vb.archived(remoteVersion.archived) } vm.update(vb.build()) if (isReleasedDiff) { vm.releaseVersion(localJiraV, remoteVersion.released) } } } // Log sucessful update of Verion // ProgressLog.logVersionChange("UPDATE VERSION", localProjectKey, localJiraV.name) } def mapping = storage.getByRemote(remoteVersion.id as String) if (mapping) { def localJiraV = vm.getVersion(mapping.local as Long) if (localJiraV == null) { storage.delete(mapping) createVersion() } else { updateVersion(localJiraV) } } else { if (matchByName) { def localVersionId = nodeHelper.getVersion(remoteVersion.name, nodeHelper.getProject(localProjectKey))?.id if (localVersionId != null) { def localJiraV = vm.getVersion(localVersionId as Long) if (localJiraV) { updateVersion(localJiraV) } else { createVersion() } } else { createVersion() } } else { createVersion() } } nodeHelper.getVersion(remoteVersion.name, nodeHelper.getProject(localProjectKey)) as BasicHubVersion } catch (IssueTrackerException ite) { throw ite } catch (Exception e) { throw new com.exalate.api.exception.IssueTrackerException("Contact Exalate Support - failed to receive for remote remoteVersions `${["name":remoteVersion.name, "id":remoteVersion.id, "projectKey":remoteVersion.projectKey] }` for local `$localProjectKey`".toString(), e) } } } catch (com.exalate.api.exception.IssueTrackerException ite) { throw ite } catch (Exception e) { throw new com.exalate.api.exception.IssueTrackerException("Contact Exalate Support - failed to receive for remote remoteVersions `${remoteVersions.collect { remoteVersion -> ["name":remoteVersion.name, "id":remoteVersion.id, "projectKey":remoteVersion.projectKey] } }` for local `$localProjectKey`".toString(), e) } } private static class ExalateVersionEventListener { static LOG = org.slf4j.LoggerFactory.getLogger("com.exalate.scripts.Versions.ExalateVersionEventListener") static String getKey(Integer connectionId) { "com.exalate.scripts.ExalateVersionEventListener.connections.$connectionId".toString() } static listen(Integer connectionId, String connectionName) { def ep = getEp() def entries = ep.listenerInvokers.entries() def listenerKey = getKey(connectionId) def exalateVersionListeners = entries.findAll { listenerInvokerEntry -> def listenerInvoker = listenerInvokerEntry.value listenerInvoker.key == listenerKey } LOG.trace(""" The number listeners for key `$listenerKey`: ${exalateVersionListeners.size()} """.toString()) if (exalateVersionListeners.size() < 1) { ep.registerListener(listenerKey, new ExalateVersionEventListener(connectionId, connectionName)) } } static unlisten(Integer connectionId) { def listenerKey = getKey(connectionId) def ep = getEp() ep.unregisterListener(listenerKey) } private static com.atlassian.event.api.EventPublisher getEp() { com.atlassian.jira.component.ComponentAccessor.getComponent(com.atlassian.event.api.EventPublisher.class) } final Integer connectionId final String connectionName final String listenerKey ExalateVersionEventListener(Integer connectionId, String connectionName) { this.connectionId = connectionId this.connectionName = connectionName this.listenerKey = getKey(connectionId) } @com.atlassian.event.api.EventListener void onJiraEvent (Object jiraEventObj) { try { //safety precaution - unregister this listener if Exalate plugin is disabled if (jiraEventObj instanceof com.atlassian.plugin.event.events.PluginDisabledEvent) { def pde = jiraEventObj as com.atlassian.plugin.event.events.PluginDisabledEvent def plugin = pde.plugin if (plugin.key == "com.exalate.jiranode") { LOG.info(""" The Exalate app is being disabled. Unregistering the ExalateVersionEventListener. """.toString()) unregister() return } } def _PluginDisablingEventClazz = null try { _PluginDisablingEventClazz = com.atlassian.jira.component.ComponentAccessor.classLoader.loadClass("com.atlassian.plugin.event.events.PluginDisablingEvent") } catch (Exception ignore) { // ... } if (_PluginDisablingEventClazz && _PluginDisablingEventClazz.isAssignableFrom(jiraEventObj.getClass())) { def pde = jiraEventObj def plugin = pde.plugin if (plugin.key == "com.exalate.jiranode") { LOG.info(""" The Exalate app has been disabled. Unregistering the ExalateSprintEventListener. """.toString()) unregister() return } } //whenever a version gets updated, find the first issue under sync by connection //trigger sync for it final def jiraCl = com.atlassian.jira.component.ComponentAccessor.classLoader def versionEventClazz = jiraCl.loadClass("com.atlassian.jira.event.project.AbstractVersionEvent") if (versionEventClazz == null) { LOG.debug("!Stopping to track version events, since ExalateVersionEventListener failed to get the com.atlassian.jira.event.project.AbstractVersionEvent class!".toString()) unregister() return } // determine if this event is a VersionEvent if (versionEventClazz.isAssignableFrom(jiraEventObj.getClass())) { def version = jiraEventObj.version as Version //it is proved to be a version event handleVersionEvent(version) } else { if (LOG.isTraceEnabled()) { LOG.trace(""" `${jiraEventObj.class}` is not instance of `com.atlassian.jira.event.project.AbstractVersionEvent`: jiraEventObj=`$jiraEventObj` jiraEventObj.dump()=`${jiraEventObj.dump()}` jiraEventObj.class=`${jiraEventObj.class}` jiraEventObj.params=`${(jiraEventObj instanceof com.atlassian.jira.event.JiraEvent) ? (jiraEventObj as com.atlassian.jira.event.JiraEvent).params : "" }` """.toString()) } } } catch (Exception e) { //safety precaution if this listener has bugs, we unregister it //but we also log the problems into the log file LOG.error(""" The ExalateVersionEventListener from VersionSync.groovy had a problem: `${e.message}` Please contact Exalate support team, changes to sprints would only be synchronized if changes to the issues within these sprints are performed. """.toString(), e) try { unregister() } catch (Exception ignore) { } } } private void unregister(){ def ep = com.atlassian.jira.component.ComponentAccessor.getComponent(com.atlassian.event.api.EventPublisher.class) ep.unregisterListener(listenerKey) } private void handleVersionEvent(Version version) { def nserv = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class) def sserv = com.atlassian.jira.component.ComponentAccessor.getComponent(com.atlassian.jira.bc.issue.search.SearchService.class) def proxyAppUser = nserv.proxyUser //com.atlassian.jira.user.ApplicationUser u def parseQuery = { u, String qStr -> try { // Jira 7 way return sserv.parseQuery(u, qStr) } catch (MissingMethodException ignore) { // Jira 6 way return sserv.parseQuery(u.getDirectoryUser(), qStr) } } //com.atlassian.jira.user.ApplicationUser u def searchOverrideSecurity = { u, finalQuery -> try { // Jira 7 way return sserv.searchOverrideSecurity(u, finalQuery, com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter()) } catch (MissingMethodException ignore) { // Jira 6 way: // SearchResults search(User var1, Query var2, PagerFilter var3) throws SearchException; //noinspection GroovyAssignabilityCheck return sserv.search(u.getDirectoryUser(), finalQuery, com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter()) } } def search = { String jql -> //get board query def queryRes = parseQuery(proxyAppUser, jql) if (!queryRes.valid) { LOG.error(""" !Stopping to track sprint events, since ExalateSprintEventListener failed to search for issues in sprint! Failed to parse JQL `$jql`: `${queryRes.errors.errorMessages}`. Please review the script.""".toString()) unregister() throw new Exception("Failed to parse JQL `$jql`: `${queryRes.errors.errorMessages}`. Please review the script.".toString()) } def query = queryRes.query // find issues on board def sRes = searchOverrideSecurity(proxyAppUser, query) def issues = sRes.issues def issuekeys = issues.collect { new com.exalate.basic.domain.BasicIssueKey( it.id as Long, it.key ) } issuekeys } def getIssuesInVersion = { if (version == null) { return [] } def cfm = com.atlassian.jira.component.ComponentAccessor.customFieldManager def versionCfs = cfm.getCustomFieldObjects() .findAll { cf -> cf.customFieldType.key == "com.atlassian.jira.issue.customfields.impl.VersionCFType" } def cfClausesStrs = versionCfs .collect { cf -> cf.clauseNames.jqlFieldNames.find() } .collect { jqlClauseName -> "$jqlClauseName = ${version.id}".toString() } def joinedCfClausesStr = cfClausesStrs.join(" OR ") def cfClausesStr = cfClausesStrs.empty ? "" : " OR $joinedCfClausesStr".toString() def _SystemSearchConstants = com.atlassian.jira.component.ComponentAccessor.classLoader.loadClass("com.atlassian.jira.issue.search.constants.SystemSearchConstants") def projectFieldName = _SystemSearchConstants.forProject().getJqlClauseNames().getPrimaryName() def fixVersionsJqlFieldName = _SystemSearchConstants.forFixForVersion().getJqlClauseNames().getPrimaryName() def affectedVersionsJqlFieldName = _SystemSearchConstants.forAffectedVersion().getJqlClauseNames().getPrimaryName() final def jql = "${projectFieldName} = ${version.project.id} AND (${fixVersionsJqlFieldName} = ${version.id} OR ${affectedVersionsJqlFieldName} = ${version.id}$cfClausesStr)".toString() search(jql) } def issuesInVersion = getIssuesInVersion(version) syncFirstIssue(issuesInVersion) } private def syncFirstIssue(List issues) { def ttrepo = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.persistence.twintrace.ITwinTraceRepository.class) def ess = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.replication.out.IEventSchedulerService.class) def con = ExalateApi.getConnection(connectionId) if (con) { issues.inject(null) { result, ik -> if (result != null) return result else { def tt = ttrepo.getTwinTraceByLocalIssueKey( con, ik ) if (tt != null) { ess.scheduleSyncEvent(ik, tt) } tt } } } else { LOG.debug("The connection `${connectionName}` ($connectionId) seems to be gone now. Stopping the version sync listener!".toString()) unregister() } } } private static class MappingStorageSupplier { static MappingStorage get(String key) { def _PluginSettingsFactory = ComponentAccessor.classLoader.loadClass("com.atlassian.sal.api.pluginsettings.PluginSettingsFactory") def psf = ComponentAccessor.getOSGiComponentInstanceOfType(_PluginSettingsFactory) def collectionPs = psf.createSettingsForKey(key) def metadataPs = psf.createSettingsForKey("${key}._metadata") new MappingStorage(collectionPs, metadataPs) } static class Jsonifier { static JsonSlurper r = new JsonSlurper() static JsonOutput w = new JsonOutput() static String toJson(V v) { w.toJson(v) } static V fromJson(String json, Closure toVFn) { toVFn(r.parseText(json)) } } public static class MappingStorage { private static final String REMOTE_IDS_METADATA_KEY = "remoteIds" private def collectionPs private def metadataPs MappingStorage (collectionPs, metadataPs) { this.collectionPs = collectionPs this.metadataPs = metadataPs } List getAll() { def remoteIds = getRemoteIds() remoteIds.collect { String id -> getByRemote(id) } } Mapping getByLocal (String local) { getAll().find { m -> m.local == local } } Mapping getByRemote (String remote) { def remoteStr = Jsonifier.toJson(new Mapping.RemoteProjection(remote)) def mStr = collectionPs.get(remoteStr) as String if (mStr == null) { return null } Jsonifier.fromJson(mStr, { Map mJson -> new Mapping(mJson) }) } Mapping create (String local, String remote) { def m = new Mapping(local, remote) def remoteStr = Jsonifier.toJson(m.remoteFn()) def mStr = Jsonifier.toJson(m) def mByRemote = getByRemote(remoteStr) if (mByRemote == null) { collectionPs.put(remoteStr, mStr) } addRemoteId(remote) m } void delete (Mapping m) { def remoteStr = Jsonifier.toJson(m.remoteFn()) collectionPs.remove(remoteStr) removeRemoteId(m.remote) } void deleteByLocal (String local) { def m = getByLocal(local) delete(m) } void deleteByRemote (String remote) { def m = getByRemote(remote) delete(m) } private List getRemoteIds() { def localIdsStr = metadataPs.get(REMOTE_IDS_METADATA_KEY) as String def localIds = (localIdsStr ? Jsonifier.fromJson(localIdsStr, { it as List; }) : []) as List; localIds } private List addRemoteId(String remote) { def remoteIds = getRemoteIds() if (!remoteIds.contains(remote)) { remoteIds += remote metadataPs.put(REMOTE_IDS_METADATA_KEY, Jsonifier.toJson(remoteIds)) } remoteIds } private List removeRemoteId(String remote) { def remoteIds = getRemoteIds() def nRemoteIds = remoteIds.size() remoteIds -= remote if (remoteIds.size() != nRemoteIds) { metadataPs.put(REMOTE_IDS_METADATA_KEY, Jsonifier.toJson(remoteIds)) } remoteIds } } static class Mapping { final String local final String remote Mapping(Map map) { this( map.local as String, map.remote as String ) } Mapping(String local, String remote) { this.local = local this.remote = remote } LocalProjection localFn() { new LocalProjection(local) } RemoteProjection remoteFn() { new RemoteProjection(remote) } static class LocalProjection { final String local LocalProjection(String local) { this.local = local } } static class RemoteProjection { final String remote RemoteProjection(String remote) { this.remote = remote } } } } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~EXALATE API EXTERNAL SCRIPT~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ static class ExalateApi { static log = org.slf4j.LoggerFactory.getLogger("com.exalate.scripts.Epic") static IConnection getConnection(int id) { try { // Exalate 4.2.X def relrepo = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.persistence.relation.IRelationRepository.class) relrepo.getRelation(id) } catch (MissingMethodException ignore) { // Exalate 4.3.X def relrepo = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.persistence.relation.IRelationRepository.class) relrepo.getConnectionById(id) } } static IConnection getConnection(Integer id) { if (id == null) { return null } getConnection(id.intValue()) } static BasicHubIssue getLocalIssueFromRemoteId(remoteIssueId, NodeHelper nodeHelper) { if (remoteIssueId == null) { return null } def im = com.atlassian.jira.component.ComponentAccessor.getIssueManager() def tt = nodeHelper.twinTraceRepository.getTwinTraceByRemoteIssueId(nodeHelper.connection, (remoteIssueId as String)) log.debug("Got twintrace by remote issue id " + remoteIssueId + "twintrace is " + tt) def _jIssue = tt != null && tt.localReplica != null ? im.getIssueObject(tt.localReplica.issueKey.id as Long) : null log.debug("Got issue " + _jIssue + " from twintrace " + tt) if (_jIssue == null) { return null } def localIssue = nodeHelper.nodeHubObjectConverter.getHubIssue(_jIssue) log.debug("Got basic hub issue " + localIssue + " from issue " + _jIssue) return localIssue } static boolean isUnderSync(String localIssueIdStr) { if (localIssueIdStr == null) { return null } def ttRepo = ComponentAccessor.getOSGiComponentInstanceOfType(ITwinTraceRepository.class) def ttSeqFuture = ttRepo.findTwinTracesByIssueId(localIssueIdStr) def ttSeq = FutureUtils.resultInf(ttSeqFuture) as Seq; !ttSeq.isEmpty() } static com.atlassian.jira.user.ApplicationUser getProxyUser() { def nserv = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class) nserv.proxyUser } } private static class ProgressLog { private static progresslog = org.slf4j.LoggerFactory.getLogger("com.exalate.scripts.saabprogress") static void logIssueChange(String event, BasicHubIssue issue) { def created_by = issue.creator?.getUsername() def key = issue.getKey() progresslog.trace("${event}: '$key' '$created_by'") } static void logBlockingError(String event, String what) { progresslog.trace("${event}: ${what}") } static void logIssueStatusChange(String event, BasicHubIssue issue, String fromStatus, String toStatus, String stepsTaken) { def key = issue.getKey() if (stepsTaken.contains("->")) { progresslog.trace("${event}: '$key' '$fromStatus' '$toStatus' '$stepsTaken'") } else { progresslog.trace("${event}: '$key' '$fromStatus' '$toStatus'") } } static void logVersionChange(String event, String projectKey, String versionName) { progresslog.trace("${event}: '$projectKey' '$versionName'") } static void logComponentChange(String event, String projectKey, String componentName) { progresslog.trace("${event}: '$projectKey' '$componentName'") } static void logFieldChange(String event, String field, String value) { progresslog.trace("${event}: '$field' '$value'") } static void logUserChange(String event, String user, String display, String mail, String groups) { progresslog.trace("${event}: '$user' '$display' '$mail' '$groups'") } static void logSprintChange(String event, String sprint) { progresslog.trace("${event}: '$sprint'") } static void logTest(String event) { progresslog.trace("${event}") } } }