JIRA Plugin Development——Configurable Custom Field Plugin
关于JIRA Plugin开发的中文资料相当少,这可能还是由于JIRA Plugin开发在国内比较小众的原因吧,下面介绍下自己的一个JIRA Plugin开发的详细过程。
业务需求
创建JIRA ISSUE时能提供一个字段,字段内容是类似于订单号或手机号这种样式的数据,并且显示出来是一个链接,点击后可跳转到订单详情页或手机号所对应的客户的整个订单页,供用户查看和此任务工单关联的订单数据;
例如:
订单号为123456;
订单详情URL为:http://192.168.11.211?order=123456;
则字段中显示出来的只能是123456的链接,而不是完整的URL,操作的用户是看不到整个链接地址的,不管是view还是edit界面,都不会显示URL地址,用户只需输入或修改订单号,保存后点击就可以直接跳转到订单详情页面;
解决办法
对于这种需求,JIRA自带的Custom Field Plugin就无法满足了,只能自己开发,开始没想到使用可配置的Custom Field,开始的解决办法是字段Value仍保存完整的URL,只是在显示和编辑时只让用户看到订单号,这样做有几个缺点,具体如下所示:
- 必须在字段配置的Default Value中绑定URL前缀,拿上面的例子来说,就是http://192.168.11.211?order=,但是在显示和编辑时又不能让用户看到,只能在Velocity模板中去做一堆事情来完成,包括和默认URL前缀的匹配,js的处理等,限制性非常大;
- 无法实现根据订单号的搜索,例如在Issue的Search for issues中搜索订单号为123456的issue就无法实现,因为字段值本身还是整个URL,而不是单纯的订单号;
身为程序员,自然不允许自己做出的东西是上面那样的残次品,于是研究了下可配置的Custom Field Plugin的实现过程;
关于Configurable Custom Field Plugin的参考资料相当少,具体实现参考了《Practical JIRA Plugins》第三章的一个例子;
可配置的字段,就是可以为字段添加一个配置项,在配置项中保存URL前缀,Value值只存储订单号,这样可以保证可按订单号搜索相关issue;
具体实现
实现Plugin的前提是我们的环境已经准备好了,即Atlassian的SDK包已经安装成功,并且本机Java环境的配置也已经OK,具体可参考:
创建Plugin Project
切换到相应目录下,使用如下命令创建JIRA Plugin:
$ atlas-create-jira-plugin
会提示输入group-id,artifact-id,version,package,具体如下:
group-id |
com.mt.mcs.customfields |
artifact-id |
configurableURL |
version |
1.0-SNAPSHOT |
package |
com.mt.mcs.customfields.configurableurl |
group-id和artifact-id用来生成Plugin的唯一key,在本例中此Plugin的key为:com.mt.mcs.customfields.configurableurl;
version用在pom.xml中,并且是生成的.jar文件名种的一部分;
package是编写源码使用的Java包名;
之后会出现提示是否确认构建此Plugin,输入"Y"或"y"即可;
将项目导入IDE
我是用的是idea,操作很简单,只需Import Project—>当前Plugin的根目录(即pom.xml文件所在的目录),点击pom.xml后,点击导入,一路next即可(选择Java环境时记得选择你配置好的Java版本),具体可参考:https://developer.atlassian.com/docs/developer-tools/working-in-an-ide/configure-idea-to-use-the-sdk;
如果使用Eclipse,可参考:https://developer.atlassian.com/docs/getting-started/set-up-the-atlassian-plugin-sdk-and-build-a-project/set-up-the-eclipse-ide-for-linux;
修改pom.xml
添加你的组织或公司名称以及你网址的URL到<organization>,具体如下所示:
<organization> <name>Example Company</name> <url>http://www.example.com/</url> </organization>
修改<description>元素;
<description>This plugin is used for an URL which can config prefix.</description>
添加customfield-type到atlassian-plugin.xml
添加完成后的atlassian-plugin.xml如下所示:
1 <atlassian-plugin key="${project.groupId}.${project.artifactId}" name="${project.name}" plugins-version="2"> 2 <plugin-info> 3 <description>${project.description}</description> 4 <version>${project.version}</version> 5 <vendor name="${project.organization.name}" url="${project.organization.url}" /> 6 <param name="plugin-icon">images/pluginIcon.png</param> 7 <param name="plugin-logo">images/pluginLogo.png</param> 8 </plugin-info> 9 10 <!-- add our i18n resource --> 11 <resource type="i18n" name="i18n" location="configurableURL"/> 12 13 <!-- import from the product container --> 14 <component-import key="applicationProperties" interface="com.atlassian.sal.api.ApplicationProperties" /> 15 16 <customfield-type key="configurable-url" 17 name="Configurable URL" 18 class="com.mt.mcs.customfields.configurableurl.PrefixUrlCFType"> 19 <description> 20 The Prefix URL Custom Field Type Plugin ... 21 </description> 22 <resource type="velocity" 23 name="view" 24 location="templates/com/mt/mcs/customfields/configurableurl/view.vm"></resource> 25 <resource type="velocity" 26 name="edit" 27 location="templates/com/mt/mcs/customfields/configurableurl/edit.vm"></resource> 28 </customfield-type> 29 </atlassian-plugin>
第一行key="${project.groupId}.${project.artifactId}",表示此plugin的唯一标识;
<customfield-type key="configurable-url" ...中的key为此customfield-type的唯一标识,要求在atlassian-plugin.xml中是唯一的;
name="Configurable URL",name为此custom field type在JIRA中显示的名字;
class="com.meituan.mcs.customfields.configurableurl.PrefixUrlCFType">,class为实现custom field type的Java类;
resource元素中包含了view和edit时,此字段使用的Velocity模板引擎;
创建CustomField Type的Class
现在我们需要创建一个Java类,实现CustomFieldType接口,并实现新的custom field type的各项功能,在类名末尾附加"CFType"是一个通用的约定,例如在我们的例子中,使用的Java类名为PrefixUrlCFType.java;
代码如下所示:
1 package com.mt.mcs.customfields.configurableurl; 2 7 import com.atlassian.jira.issue.Issue; 8 import com.atlassian.jira.issue.customfields.impl.FieldValidationException; 9 import com.atlassian.jira.issue.customfields.impl.GenericTextCFType; 10 import com.atlassian.jira.issue.customfields.manager.GenericConfigManager; 11 import com.atlassian.jira.issue.customfields.persistence.CustomFieldValuePersister; 12 import com.atlassian.jira.issue.fields.CustomField; 13 import com.atlassian.jira.issue.fields.config.FieldConfig; 14 import com.atlassian.jira.issue.fields.config.FieldConfigItemType; 15 import com.atlassian.jira.issue.fields.layout.field.FieldLayoutItem; 16 17 import java.util.List; 18 import java.util.Map; 19 import java.util.regex.Matcher; 20 import java.util.regex.Pattern; 21 22 public class PrefixUrlCFType extends GenericTextCFType { 23 24 public PrefixUrlCFType(CustomFieldValuePersister customFieldValuePersister, GenericConfigManager genericConfigManager) { 25 super(customFieldValuePersister, genericConfigManager); 26 } 27 28 @Override 29 public List<FieldConfigItemType> getConfigurationItemTypes() { 30 final List<FieldConfigItemType> configurationItemTypes = super.getConfigurationItemTypes(); 31 configurationItemTypes.add(new PrefixURLConfigItem()); 32 return configurationItemTypes; 33 } 34 35 @Override 36 public Map<String, Object> getVelocityParameters(final Issue issue, 37 final CustomField field, 38 final FieldLayoutItem fieldLayoutItem) { 39 final Map<String, Object> map = super.getVelocityParameters(issue, field, fieldLayoutItem); 40 41 // This method is also called to get the default value, in 42 // which case issue is null so we can't use it to add currencyLocale 43 if (issue == null) { 44 return map; 45 } 46 47 FieldConfig fieldConfig = field.getRelevantConfig(issue); 48 //add what you need to the map here50 51 return map; 52 } 53 54 public String getSingularObjectFromString(final String string) throws FieldValidationException 55 { 56 // JRA-14998 - trim the value. 57 final String value = (string == null) ? "Default" : string.trim(); 58 if (value != null && value != "Default") { 59 Pattern p = Pattern.compile("^[0-9A-Za-z]+$"); 60 Matcher m = p.matcher(value); 61 if (!m.matches()) { 62 throw new FieldValidationException("Not Valid, only support a-z, A-Z and 0-9 ..."); 63 } 64 } 65 return value; 66 } 67 }
添加配置项到Custom Field
对于每一个custom field,JIRA允许配置不同的内容,例如在不同的项目和任务类型中,select list字段就可以配置不同的option;
对于字段的配置项,我们首先要做的就是决定配置项中要存储什么值,在我们的项目中,存储的是URL前缀,使用字符串形式保存即可;
JIRA的配置项需要新定义一个类,并需要实现com.atlassian.jira.issue.fields.config.FieldConfigItemType接口,除此之外,我们还需要在JIRA中定义一个新的web页面,让我们填写并保存配置项的值;
代码如下所示:
1 package com.meituan.mcs.customfields.configurableurl; 2 3 import com.atlassian.jira.issue.Issue; 4 import com.atlassian.jira.issue.fields.config.FieldConfig; 5 import com.atlassian.jira.issue.fields.config.FieldConfigItemType; 6 import com.atlassian.jira.issue.fields.layout.field.FieldLayoutItem; 7 8 import java.util.HashMap; 9 import java.util.Map; 10 14 public class PrefixURLConfigItem implements FieldConfigItemType { 15 16 @Override 17 //The name of this kind of configuration, as seen in the field configuration scheme; 18 public String getDisplayName() { 19 return "Config Prefix URL"; 20 } 21 22 @Override 23 // This is the text shown in the field configuration screen; 24 public String getDisplayNameKey() { 25 return "Prefix Of The URL"; 26 } 27 28 @Override 29 // This is the current value as shown in the field configuration screen 30 public String getViewHtml(FieldConfig fieldConfig, FieldLayoutItem fieldLayoutItem) { 31 String prefix_url = DAO.getCurrentPrefixURL(fieldConfig); 32 return prefix_url; 33 } 34 35 @Override 36 //The unique identifier for this kind of configuration, 37 //and also the key for the $configs Map used in edit.vm 38 public String getObjectKey() { 39 return "PrefixUrlConfig"; 40 } 41 42 @Override 43 // Return the Object used in the Velocity edit context in $configs 44 public Object getConfigurationObject(Issue issue, FieldConfig fieldConfig) { 45 Map result = new HashMap(); 46 result.put("prefixurl", DAO.getCurrentPrefixURL(fieldConfig)); 47 return result; 48 } 49 50 @Override 51 // Where the Edit link should redirect to when it's clicked on 52 public String getBaseEditUrl() { 53 return "EditPrefixUrlConfig.jspa"; 54 } 55 }
DAO(Data Access Object)类的任务就是存储配置数据到数据库,具体数据存储先不在这里详细说明了,DAO类代码如下所示:
1 package com.mt.mcs.customfields.configurableurl; 2 3 import com.atlassian.jira.issue.fields.config.FieldConfig; 4 import com.opensymphony.module.propertyset.PropertySet; 5 import com.opensymphony.module.propertyset.PropertySetManager; 6 import org.apache.log4j.Logger; 7 8 import java.util.HashMap; 9 10 public class DAO { 11 12 public static final Logger log; 13 14 static { 15 log = Logger.getLogger(DAO.class); 16 } 17 18 private static PropertySet ofbizPs = null; 19 20 private static final int ENTITY_ID = 20000; 21 22 private static PropertySet getPS() { 23 if (ofbizPs == null) { 24 HashMap ofbizArgs = new HashMap(); 25 ofbizArgs.put("delegator.name", "default"); 26 ofbizArgs.put("entityName", "prefix_url_fields"); 27 ofbizArgs.put("entityId", new Long(ENTITY_ID)); 28 ofbizPs = PropertySetManager.getInstance("ofbiz", ofbizArgs); 29 } 30 return ofbizPs; 31 } 32 33 private static String getEntityName(FieldConfig fieldConfig) { 34 Long context = fieldConfig.getId(); 35 String psEntityName = fieldConfig.getCustomField().getId() + "_" + context + "_config"; 36 return psEntityName; 37 } 38 39 public static String retrieveStoredValue(FieldConfig fieldConfig) { 40 String entityName = getEntityName(fieldConfig); 41 return getPS().getString(entityName); 42 } 43 44 public static void updateStoredValue(FieldConfig fieldConfig, String value) { 45 String entityName = getEntityName(fieldConfig); 46 getPS().setString(entityName, value); 47 } 48 49 public static String getCurrentPrefixURL(FieldConfig fieldConfig) { 50 String prefixurl = retrieveStoredValue(fieldConfig); 51 log.info("Current stored prefix url is " + prefixurl); 52 if (prefixurl == null || prefixurl.equals("")) { 53 prefixurl = null; 54 } 55 return prefixurl; 56 } 57 }
做完这些之后,还需要把PrefixURLConfigItem类和PrefixUrlCFType类关联起来,需要重写getConfigurationItemTypes方法,添加后的PrefixUrlCFType类如下所示:
1 package com.mt.mcs.customfields.configurableurl; 2 3 import com.atlassian.jira.issue.Issue; 4 import com.atlassian.jira.issue.customfields.impl.FieldValidationException; 5 import com.atlassian.jira.issue.customfields.impl.GenericTextCFType; 6 import com.atlassian.jira.issue.customfields.manager.GenericConfigManager; 7 import com.atlassian.jira.issue.customfields.persistence.CustomFieldValuePersister; 8 import com.atlassian.jira.issue.fields.CustomField; 9 import com.atlassian.jira.issue.fields.config.FieldConfig; 10 import com.atlassian.jira.issue.fields.config.FieldConfigItemType; 11 import com.atlassian.jira.issue.fields.layout.field.FieldLayoutItem; 12 13 import java.util.List; 14 import java.util.Map; 15 import java.util.regex.Matcher; 16 import java.util.regex.Pattern; 17 18 public class PrefixUrlCFType extends GenericTextCFType { 19 20 public PrefixUrlCFType(CustomFieldValuePersister customFieldValuePersister, GenericConfigManager genericConfigManager) { 21 super(customFieldValuePersister, genericConfigManager); 22 } 23 24 @Override 25 public List<FieldConfigItemType> getConfigurationItemTypes() { 26 final List<FieldConfigItemType> configurationItemTypes = super.getConfigurationItemTypes(); 27 configurationItemTypes.add(new PrefixURLConfigItem()); 28 return configurationItemTypes; 29 } 30 31 @Override 32 public Map<String, Object> getVelocityParameters(final Issue issue, 33 final CustomField field, 34 final FieldLayoutItem fieldLayoutItem) { 35 final Map<String, Object> map = super.getVelocityParameters(issue, field, fieldLayoutItem); 36 37 // This method is also called to get the default value, in 38 // which case issue is null so we can't use it to add currencyLocale 39 if (issue == null) { 40 return map; 41 } 42 43 FieldConfig fieldConfig = field.getRelevantConfig(issue); 44 //add what you need to the map here 45 map.put("currentPrefixURL", DAO.getCurrentPrefixURL(fieldConfig)); 46 47 return map; 48 } 49 50 public String getSingularObjectFromString(final String string) throws FieldValidationException 51 { 52 // JRA-14998 - trim the value. 53 final String value = (string == null) ? "Default" : string.trim(); 54 if (value != null && value != "Default") { 55 Pattern p = Pattern.compile("^[0-9A-Za-z]+$"); 56 Matcher m = p.matcher(value); 57 if (!m.matches()) { 58 throw new FieldValidationException("Not Valid, only support a-z, A-Z and 0-9 ..."); 59 } 60 } 61 return value; 62 } 63 }
Velocity模板引擎
custom field在JIRA中显示和编辑,需要使用Velocity模板,即view.vm和edit.vm,具体如下所示:
view.vm
1 #disable_html_escaping() 2 #set($defaultValue = "Default") 3 #if ($value && $value != $defaultValue) 4 #if ($currentPrefixURL) 5 <a class="tinylink" target="_blank" href="$currentPrefixURL$value">$!textutils.htmlEncode($value)</a> 6 #else 7 #set($displayValue = "没有配置URL前缀...") 8 $!textutils.htmlEncode($displayValue) 9 #end 10 #elseif ($value == $defaultValue) 11 #set($displayValue = "请输入相关信息...") 12 $textutils.htmlEncode($displayValue) 13 #else 14 #set($displayValue = "出现错误了....") 15 $textutils.htmlEncode($displayValue) 16 #end
edit.vm
1 #disable_html_escaping() 2 #customControlHeader ($action $customField.id $customField.name $fieldLayoutItem.required $displayParameters $auiparams) 3 #set($configObj = $configs.get("PrefixUrlConfig")) 4 #set($prefixUrl = $configObj.get("prefixurl")) 5 #set($defaultValue = "Default") 6 #if ($value == $defaultValue) 7 <input class="text" id="displayText" name="displayText" type="text" value="" onchange="changeValue(${customField.id})"> 8 <input class="text" id="$customField.id" name="$customField.id" type="hidden" value="$textutils.htmlEncode($!value)"> 9 #else 10 <input class="text" id="$customField.id" name="$customField.id" type="text" value="$textutils.htmlEncode($!value)"> 11 #end 12 <script type="text/javascript"> 13 function changeValue(cfElmId) { 14 var cfElmId = cfElmId.id; 15 var element = document.getElementById("displayText"); 16 var elmVal = element.value; 17 var cfElm = document.getElementById(cfElmId); 18 cfElm.value = elmVal; 19 } 20 </script> 21 #customControlFooter ($action $customField.id $fieldLayoutItem.fieldDescription $displayParameters $auiparams)
WebWork Action
到现在为止,我们定义了一个新类型的配置项,并且更新了PrefixUrlCFType类和Velocity模板引擎,我们还需要一个新的web页面,来设置配置项的值(即URL前缀信息)并保存到数据库;
JIRA是通过WebWork web应用框架来定义web页面的,需要在atlassian-plugin.xml文件中配置webwork元素,具体如下所示:
1 <webwork1 key="url-configurable" 2 name="URL configuration action" 3 class="java.lang.Object"> 4 <description> 5 The action for editing a prefix url custom field type configuration. 6 </description> 7 <actions> 8 <action name="com.mt.mcs.customfields.configurableurl.EditPrefixUrlConfig" 9 alias="EditPrefixUrlConfig"> 10 <view name="input"> 11 /templates/com/mt/mcs/customfields/configurableurl/edit-config.vm 12 </view> 13 <view name="securitybreach"> 14 /secure/views/securitybreach.jsp 15 </view> 16 </action> 17 </actions> 18 </webwork1>
使用的edit-config.vm模板文件代码如下所示:
1 <html> 2 <head> 3 <title> 4 $i18n.getText('common.words.configure') 5 $action.getCustomField().getName() 6 </title> 7 <meta content="admin" name="decorator"> 8 <link rel="stylesheet" type="text/css" media="print" href="/styles/combined-printtable.css"> 9 <link rel="stylesheet" type="text/css" media="all" href="/styles/combined.css"> 10 <style> 11 table.base-table { 12 margin: 15px auto; 13 border-spacing: 5px 10px; 14 line-height: 1.5; 15 font-size: 16px; 16 } 17 input.prefixurl { 18 outline: none; 19 box-shadow: bisque; 20 width: 350px !important; 21 } 22 table.base-table input#Save { 23 margin-left: 80px; 24 } 25 </style> 26 </head> 27 <body> 28 <h2 class="formtitle"> 29 $i18n.getText('common.words.configure') $action.getCustomField().getName() 30 </h2> 31 <div class="aui-message aui-message-info"> 32 <p class="title"> 33 <span class="aui-icon icon-info"></span> 34 <strong>Notice</strong> 35 </p> 36 <p> 37 Config the prefix of your URL. 38 </p> 39 <p> 40 At the end of the URL, you need to add a '/', such as 'http://192.168.11.234/' ! 41 </p> 42 </div> 43 <form action="EditPrefixUrlConfig.jspa" method="post" class="aui"> 44 <table class="base-table"> 45 <tr> 46 <td> 47 Prefix Url: 48 </td> 49 <td> 50 #set($prefix_url = $action.getPrefixurl()) 51 <input type="text" name="prefixurl" id="prefixurl" value="$!prefix_url" class="text prefixurl"> 52 </td> 53 </tr> 54 <tr> 55 <td colspan="2"> 56 <input type="submit" name="Save" id="Save" value="$i18n.getText('common.words.save')" class="aui-button"> 57 <a href="ConfigureCustomField!default.jspa?customFieldId=$action.getCustomField().getIdAsLong().toString()" 58 id="cancelButton" class="aui-button" name="ViewCustomFields.jspa"> 59 Cancel 60 </a> 61 </td> 62 </tr> 63 </table> 64 <input type="hidden" name="fieldConfigId" value="$fieldConfigId"> 65 </form> 66 </body> 67 </html>
Action Class
配置项的web页面使用的Action类是EditPrefixUrlConfig.java,代码如下所示:
1 package com.mt.mcs.customfields.configurableurl; 2 3 import com.atlassian.jira.config.managedconfiguration.ManagedConfigurationItemService; 4 import com.atlassian.jira.issue.customfields.impl.FieldValidationException; 5 import com.atlassian.jira.security.Permissions; 6 import com.atlassian.jira.web.action.admin.customfields.AbstractEditConfigurationItemAction; 7 import com.opensymphony.util.UrlUtils; 8 9 public class EditPrefixUrlConfig extends AbstractEditConfigurationItemAction { 10 11 protected EditPrefixUrlConfig(ManagedConfigurationItemService managedConfigurationItemService) { 12 super(managedConfigurationItemService); 13 } 14 15 private String prefixurl; 16 17 public void setPrefixurl(String prefixurl) { 18 this.prefixurl = prefixurl; 19 } 20 21 public String getPrefixurl() { 22 return this.prefixurl; 23 } 24 25 protected void doValidation() { 26 String prefix_url = getPrefixurl(); 27 prefix_url = (prefix_url == null) ? null : prefix_url.trim(); 28 if (prefix_url == null) { 29 return; 30 } 31 if (!UrlUtils.verifyHierachicalURI(prefix_url)) { 32 addErrorMessage("ERROR: " + prefix_url + " is not a valid URL..."); 33 } 34 } 35 36 protected String doExecute() throws Exception { 37 if (!isHasPermission(Permissions.ADMINISTER)) { 38 return "securitybreach"; 39 } 40 if (getPrefixurl() == null) { 41 setPrefixurl(DAO.retrieveStoredValue(getFieldConfig())); 42 } 43 DAO.updateStoredValue(getFieldConfig(), getPrefixurl()); 44 String save = request.getParameter("Save"); 45 if (save != null && save.equals("Save")) { 46 setReturnUrl("/secure/admin/ConfigureCustomField!default.jspa?customFieldId=" + getFieldConfig().getCustomField().getIdAsLong().toString()); 47 return getRedirect("not used"); 48 } 49 return INPUT; 50 } 51 }
这样整个可配置的Custom Field Plugin已经正式开发完成了,只是搜索功能还没有实现,搜索只是继承已有的Searcher即可,本例继承的是TextSearcher;
Searcher的实现可参考:https://www.safaribooksonline.com/library/view/practical-jira-plugins/9781449311322/ch04.html,讲解非常详细;