Commits

Penny Wyatt Wyatt  committed 2aace20

Renamed 'ui' package to 'jiraui' and created a 'stashui' package with a test servlet as proof of concept of cross-productness.

  • Participants
  • Parent commits 2e2bfb8

Comments (0)

Files changed (15)

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/HelperTabPanel.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import java.util.List;
+
+import com.atlassian.crowd.embedded.api.User;
+import com.atlassian.jira.issue.Issue;
+import com.atlassian.jira.issue.RendererManager;
+import com.atlassian.jira.plugin.issuetabpanel.GetActionsReply;
+import com.atlassian.jira.plugin.issuetabpanel.GetActionsRequest;
+import com.atlassian.jira.plugin.issuetabpanel.IssueAction;
+import com.atlassian.jira.plugin.issuetabpanel.IssueTabPanel2;
+import com.atlassian.jira.plugin.issuetabpanel.IssueTabPanelModuleDescriptor;
+import com.atlassian.jira.plugin.issuetabpanel.ShowPanelReply;
+import com.atlassian.jira.plugin.issuetabpanel.ShowPanelRequest;
+import com.atlassian.jira.security.Permissions;
+
+import com.atlassian.jira.component.ComponentAccessor;
+import com.atlassianqa.jiraqahelper.services.commits.IssueCommitData;
+import com.atlassianqa.jiraqahelper.services.hints.Hint;
+import com.atlassianqa.jiraqahelper.services.hints.HintService;
+import com.atlassianqa.jiraqahelper.services.rules.Rule;
+import com.atlassianqa.jiraqahelper.services.rules.RuleService;
+import com.atlassianqa.jiraqahelper.services.settings.PluginSettingsService;
+import com.atlassianqa.jiraqahelper.services.stash.InvalidStashJsonException;
+import com.atlassianqa.jiraqahelper.services.stash.NoStashAppLinkException;
+import com.atlassianqa.jiraqahelper.services.stash.StashAuthenticationRequiredException;
+import com.atlassianqa.jiraqahelper.services.stash.StashConnectionFailedException;
+import com.atlassianqa.jiraqahelper.services.stash.StashException;
+import com.atlassianqa.jiraqahelper.services.stash.StashReadResponseFailedException;
+import com.atlassianqa.jiraqahelper.services.stash.StashService;
+import com.atlassianqa.jiraqahelper.services.stash.StashTimeoutException;
+
+import java.util.ArrayList;
+
+public class HelperTabPanel implements IssueTabPanel2
+{
+    protected IssueTabPanelModuleDescriptor descriptor;
+    private StashService stashService;
+    private RuleService ruleService;
+    private HintService hintService;
+    private PluginSettingsService pluginSettingsService;    
+    private RendererManager rendererManager;
+    
+    public HelperTabPanel (StashService stashService, HintService hintService, RuleService ruleService, PluginSettingsService pluginSettingsService, RendererManager rendererManager)
+    {
+        this.stashService = stashService;
+        this.ruleService = ruleService;
+        this.hintService = hintService;
+        this.pluginSettingsService = pluginSettingsService;
+        this.rendererManager = rendererManager;
+    }
+    
+    public void init (IssueTabPanelModuleDescriptor descriptor) {
+        this.descriptor = descriptor;
+    }
+            
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public List getActions (Issue issue, User user) 
+    {
+        // Create a list of items to display to the user on the tab panel and start with the header.
+        ArrayList<IssueAction> actions = new ArrayList<IssueAction> ();
+        actions.add (new HelperTabPanelHeaderAction (descriptor, issue.getKey()));
+
+        // Get the hints from the plugin settings for this project.
+        List<Rule> rules = ruleService.getRulesForProject (issue.getProjectObject().getKey());
+        if (rules == null || rules.size() == 0) {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.norules", null, true));
+            return actions;
+        }
+
+        // Get the commits from Stash and handle all the things that could go wrong.
+        IssueCommitData commits = null;
+        try {
+            commits = stashService.getCommits(issue.getKey());
+            if (commits == null || commits.getFileDiffs() == null || commits.getFileDiffs().size() == 0) 
+            {
+                actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nocommits", null, false));
+                return actions;
+            }
+            // Get the hints for this set of commits.
+            List<Hint> hints = hintService.getHints(rules, commits, stashService);
+            if (hints == null || hints.size() == 0)
+            {
+                actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nohints", null, false));
+            }
+            else
+            {
+                for (Hint hint : hints)
+                {
+                    actions.add (new HelperTabPanelHintAction (descriptor, rendererManager, issue, hint));
+                }
+            }
+        } 
+        catch (StashAuthenticationRequiredException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.authrequired", e.getUrl(), true));
+        } 
+        catch (StashConnectionFailedException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.connectionfailed", e.getUrl(), true));
+        } 
+        catch (StashReadResponseFailedException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.readresponse", e.getUrl(), true));
+        } 
+        catch (NoStashAppLinkException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nostashapplink", null, true));
+        } 
+        catch (InvalidStashJsonException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.badjson", null, true));
+        } 
+        catch (StashTimeoutException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.timeout", null, true));
+        } 
+        catch (StashException e) 
+        {
+            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.unknown", null, true));
+        }
+        return actions;
+    }
+    
+    public boolean showPanel (Issue issue, User remoteUser) 
+    {
+        // Show the tab panel...
+        // ... if the plugin is configured to let us see it...
+        if (remoteUser != null && pluginSettingsService.isUserAllowed(remoteUser.getName()))
+        {
+            // ... and if the current user has permission to see the Source tab...
+            if (ComponentAccessor.getPermissionManager().hasPermission(Permissions.VIEW_VERSION_CONTROL, issue, remoteUser))
+            {
+                // ... and an applink to Stash exists...
+                if (stashService.isConfigured ())
+                {
+                    // ... and if rules have been configured for this project.
+                    List<Rule> rules = ruleService.getRulesForProject (issue.getProjectObject().getKey());
+                    return (rules != null && rules.size() > 0);
+                }
+            }
+        }
+        return false;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public GetActionsReply getActions(GetActionsRequest request) 
+    {
+        // If we're being called asynchronously, take the time to generate the full tab panel.
+        if (request.isAsynchronous())
+        {
+            return GetActionsReply.create (this.getActions (request.issue(), request.remoteUser()));
+        }
+        // If we're not, we're holding things up. Just serve up some javascript that loads the tab panel asynchronously.
+        ArrayList<IssueAction> actions = new ArrayList<IssueAction> ();
+        actions.add (new HelperTabPanelNonAjaxAction (descriptor, request.issue()));
+        return GetActionsReply.create (actions);  
+    }
+
+    @Override
+    public ShowPanelReply showPanel(ShowPanelRequest request) 
+    {
+        return ShowPanelReply.create(this.showPanel(request.issue(), request.remoteUser()));
+    }
+}

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/HelperTabPanelErrorAction.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import java.util.Date;
+import com.atlassian.jira.plugin.issuetabpanel.*;
+import java.util.Map;
+import java.util.HashMap;
+
+public class HelperTabPanelErrorAction extends AbstractIssueAction
+{
+    private String errorKey;
+    private String url;
+    private boolean isError;
+
+    public HelperTabPanelErrorAction (IssueTabPanelModuleDescriptor descriptor, String errorKey, String url, boolean isError)
+    {
+        super(descriptor);
+        this.errorKey = errorKey;
+        this.url = url;
+        this.isError = isError;
+    }
+
+    public String getHtml()
+    {
+        HashMap<String, Object> params = new HashMap <String, Object> ();
+        params.put ("errorKey", errorKey);
+        params.put ("url", url);
+        params.put ("type", isError ? "error" : "info");
+        return descriptor.getHtml ("helper-tabpanel-error", params);
+    }
+
+    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
+    {
+    }
+
+    public Date getTimePerformed()
+    {
+        return new Date();
+    }
+
+    public boolean isDisplayActionAllTab()
+    {
+        return false;
+    }
+}
+

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/HelperTabPanelHeaderAction.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import java.util.Date;
+import com.atlassian.jira.plugin.issuetabpanel.*;
+
+import java.util.Map;
+import java.util.HashMap;
+
+public class HelperTabPanelHeaderAction extends AbstractIssueAction
+{
+    private String issueKey;
+
+    public HelperTabPanelHeaderAction (IssueTabPanelModuleDescriptor descriptor, String issueKey)
+    {
+        super(descriptor);
+        this.issueKey = issueKey;
+    }
+
+    public String getHtml()
+    {
+        HashMap<String, Object> params = new HashMap <String, Object> ();
+        params.put ("issueKey", issueKey);
+        return descriptor.getHtml ("helper-tabpanel-header", params);
+    }
+
+    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
+    {
+    }
+
+    public Date getTimePerformed()
+    {
+        return new Date();
+    }
+
+    public boolean isDisplayActionAllTab()
+    {
+        return false;
+    }
+}
+

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/HelperTabPanelHintAction.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import java.util.Date;
+import com.atlassian.jira.plugin.issuetabpanel.*;
+import com.atlassianqa.jiraqahelper.services.hints.Hint;
+import com.atlassian.jira.issue.Issue;
+import com.atlassian.jira.issue.RendererManager;
+import com.atlassian.jira.issue.fields.renderer.wiki.*;
+
+import java.util.Map;
+import java.util.HashMap;
+
+public class HelperTabPanelHintAction extends AbstractIssueAction
+{
+    private Hint hint;
+    private Issue issue;
+    private RendererManager rendererManager;
+
+    public HelperTabPanelHintAction (IssueTabPanelModuleDescriptor descriptor, RendererManager rendererManager, Issue issue, Hint hint)
+    {
+        super(descriptor);
+        this.hint = hint;
+        this.issue = issue;
+        this.rendererManager = rendererManager;
+    }
+
+    public String getHtml()
+    {
+        HashMap<String, Object> params = new HashMap <String, Object> ();
+        String hintHtml = rendererManager.getRenderedContent(AtlassianWikiRenderer.RENDERER_TYPE, hint.getRule().getDescription(), issue.getIssueRenderContext());
+        params.put ("hint", hint);
+        params.put ("hintHtml", hintHtml);
+        return descriptor.getHtml ("helper-tabpanel-hint", params);
+    }
+
+    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
+    {
+    }
+
+    public Date getTimePerformed()
+    {
+        return new Date();
+    }
+
+    public boolean isDisplayActionAllTab()
+    {
+        return false;
+    }
+}
+

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/HelperTabPanelNonAjaxAction.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import java.util.Date;
+
+import com.atlassian.jira.issue.Issue;
+import com.atlassian.jira.plugin.issuetabpanel.*;
+
+import java.util.Map;
+import java.util.HashMap;
+
+public class HelperTabPanelNonAjaxAction extends AbstractIssueAction
+{
+    private Issue issue;
+    
+    public HelperTabPanelNonAjaxAction (IssueTabPanelModuleDescriptor descriptor, Issue issue)
+    {
+        super(descriptor);
+        this.issue = issue;
+    }
+
+    public String getHtml()
+    {
+        HashMap<String, Object> params = new HashMap <String, Object> ();
+        params.put("issueKey", issue.getKey());
+        return descriptor.getHtml ("helper-tabpanel-nonajax", params);
+    }
+
+    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
+    {
+    }
+
+    public Date getTimePerformed()
+    {
+        return new Date();
+    }
+
+    public boolean isDisplayActionAllTab()
+    {
+        return false;
+    }
+}
+

File src/main/java/com/atlassianqa/jiraqahelper/jiraui/UploadDataAction.java

+package com.atlassianqa.jiraqahelper.jiraui;
+
+import com.atlassian.jira.security.Permissions;
+import com.atlassian.jira.security.xsrf.RequiresXsrfCheck;
+import com.atlassian.jira.web.action.JiraWebActionSupport;
+import com.atlassianqa.jiraqahelper.services.rules.RuleService;
+import com.atlassianqa.jiraqahelper.services.settings.PluginSettingsService;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Scanner;
+
+import org.codehaus.jackson.JsonParseException;
+import org.codehaus.jackson.JsonParser;
+import org.codehaus.jackson.map.ObjectMapper;
+
+import webwork.multipart.MultiPartRequestWrapper;
+
+public class UploadDataAction extends JiraWebActionSupport 
+{
+    private static final long serialVersionUID = -4711771991379272266L;
+    private static final String SUCCESS = "success";
+    private static final String SECURITY_BREACH = "securitybreach";
+    private static final String FILE_FORM_KEY = "datafile";
+    private static final String USER_FORM_KEY = "allowedusers";
+    private PluginSettingsService pluginSettingsService;
+    private RuleService ruleService;
+    
+    public UploadDataAction (PluginSettingsService pluginSettingsService, RuleService ruleService) 
+    {
+        this.pluginSettingsService = pluginSettingsService;
+        this.ruleService = ruleService;
+    }
+
+    public String doDefault () throws Exception 
+    {
+        if (getLoggedInUser() == null || !isHasPermission(Permissions.ADMINISTER)) {
+            return SECURITY_BREACH;
+        }
+        return SUCCESS;
+    }
+    
+    public String getData ()
+    {
+        return pluginSettingsService.getRulesJson();
+    }
+    
+    public String getError ()
+    {
+        return pluginSettingsService.getErrorString();
+    }
+    
+    public String getAllowedUsers ()
+    {
+        return pluginSettingsService.getAllowedUsers();
+    }
+
+    @RequiresXsrfCheck
+    protected String doExecute () throws Exception 
+    {
+        if (getLoggedInUser() == null || !isHasPermission(Permissions.ADMINISTER)) 
+        {
+            return SECURITY_BREACH;
+        }
+        
+        // Store the new JSON rules data
+        MultiPartRequestWrapper wrapper = (MultiPartRequestWrapper) request;
+        File uploadedFile = wrapper.getFile(FILE_FORM_KEY);
+        if (uploadedFile == null)
+        {
+            // If there was no new file uploaded, leave the JSON as-is.
+        }
+        else
+        {
+            String dataFileContent = new Scanner(uploadedFile).useDelimiter("\\Z").next();
+            pluginSettingsService.setRulesJson (dataFileContent);
+            ruleService.refreshRulesFromSettings(pluginSettingsService);
+            pluginSettingsService.setErrorString (getValidationError(dataFileContent));
+        }
+        
+        // request.getParameter (USER_FORM_KEY) always returns null, so we use the array, even though we only want one value.
+        String [] allowedUsersArr = request.getParameterValues (USER_FORM_KEY);
+        String allowedUsers = null;
+        if (allowedUsersArr != null && allowedUsersArr.length > 0)
+        {
+            allowedUsers = allowedUsersArr[0];
+        }
+        pluginSettingsService.setAllowedUsers (allowedUsers);
+        
+        return SUCCESS;
+    }
+    
+    private String getValidationError (String json)
+    {
+        // Check that the JSON is valid and return an error string if not.
+        String error = null;
+        try 
+        { 
+            JsonParser parser = new ObjectMapper().getJsonFactory().createJsonParser(json); 
+            while (parser.nextToken() != null); 
+            return null;
+        } 
+        catch (JsonParseException jpe) 
+        { 
+            error = jpe.getMessage(); 
+        }
+        catch (IOException ioe) 
+        { 
+            error = ioe.getMessage();
+        }         
+        if (error == null) 
+        {
+            return null;
+        }
+        // The second line of the error message is not useful to end-users.
+        // Just take the first.
+        return error.split("\\n")[0];
+    }
+}

File src/main/java/com/atlassianqa/jiraqahelper/stashui/HelperServlet.java

+package com.atlassianqa.jiraqahelper.stashui;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import com.atlassian.jira.issue.RendererManager;
+import com.atlassian.jira.plugin.issuetabpanel.IssueAction;
+import com.atlassian.sal.api.message.I18nResolver;
+import com.atlassianqa.jiraqahelper.jiraui.HelperTabPanelErrorAction;
+import com.atlassianqa.jiraqahelper.jiraui.HelperTabPanelHeaderAction;
+import com.atlassianqa.jiraqahelper.jiraui.HelperTabPanelHintAction;
+import com.atlassianqa.jiraqahelper.services.commits.FileDiff;
+import com.atlassianqa.jiraqahelper.services.commits.IssueCommitData;
+import com.atlassianqa.jiraqahelper.services.hints.Hint;
+import com.atlassianqa.jiraqahelper.services.hints.HintService;
+import com.atlassianqa.jiraqahelper.services.rules.Rule;
+import com.atlassianqa.jiraqahelper.services.rules.RuleService;
+import com.atlassianqa.jiraqahelper.services.settings.PluginSettingsService;
+import com.atlassianqa.jiraqahelper.services.stash.InvalidStashJsonException;
+import com.atlassianqa.jiraqahelper.services.stash.NoStashAppLinkException;
+import com.atlassianqa.jiraqahelper.services.stash.StashAuthenticationRequiredException;
+import com.atlassianqa.jiraqahelper.services.stash.StashConnectionFailedException;
+import com.atlassianqa.jiraqahelper.services.stash.StashException;
+import com.atlassianqa.jiraqahelper.services.stash.StashReadResponseFailedException;
+import com.atlassianqa.jiraqahelper.services.stash.StashService;
+import com.atlassianqa.jiraqahelper.services.stash.StashTimeoutException;
+
+public class HelperServlet extends HttpServlet 
+{
+    private static final long serialVersionUID = -4662145944539752602L;
+    private StashService stashService;
+    private HintService hintService;
+    private RuleService ruleService;
+    private I18nResolver i18n;
+
+    public HelperServlet (I18nResolver i18n, StashService stashService, HintService hintService, RuleService ruleService, PluginSettingsService pluginSettingsService)
+    {
+        this.stashService = stashService;
+        this.hintService = hintService;
+        this.ruleService = ruleService;
+        this.i18n = i18n;
+        // todo add admin screen for setting this stuff in Stash 
+        pluginSettingsService.setRulesJson(
+                        "{" +
+                        "    \"projects\": " +
+                        "    [" +
+                        "        {" +
+                        "            \"key\": \"TEST\"," +
+                        "            \"rules\": " +
+                        "            [" +
+                        "                {" +
+                        "                    \"name\": \"Use of 'add' \"," +
+                        "                    \"specificity\": \"GENERIC\"," +
+                        "                    \"description\": \"You added the word 'add' to a file. If you add adding, it's like multiplying and that's totally different.\"," +
+                        "                    \"matching\":" +
+                        "                    [" +
+                        "                        {" +
+                        "                            \"property\": \"NAME\"," +
+                        "                            \"regex\": \".*\"" +
+                        "                        }," +
+                        "                        {" +
+                        "                           \"property\": \"CONTENTADDED\"," +
+                        "                           \"regex\": \".*add.*\"" +
+                        "                        }" +
+                        "                    ]" +
+                        "                }" +
+                        "            ]" +
+                        "        }" +
+                        "    ]" +
+                        "}");
+        ruleService.refreshRulesFromSettings(pluginSettingsService);
+    }
+    
+    @Override
+    protected void doGet (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException 
+    {
+        String issueKey = req.getParameter("issueKey");
+        String projKey = req.getParameter("projKey");
+        if (issueKey == null || issueKey.isEmpty() || projKey == null || projKey.isEmpty())
+        {
+            // todo: this is all just for testing
+            resp.getWriter().write("I need an issue key and project key to work with. Provide them in the URL, e.g. ?issueKey=TEST-1&projKey=TEST");
+            return;
+        }
+
+        
+        resp.addHeader("Content-Type", "text/html;charset=UTF-8");
+        // Get the hints from the plugin settings for this project.
+        List<Rule> rules = ruleService.getRulesForProject (projKey);
+        if (rules == null || rules.size() == 0) {
+            showResponse (resp, "jiraqahelper.tabpanel.error.norules", null, true);
+            return;
+        }
+
+        // Get the commits from Stash and handle all the things that could go wrong.
+        IssueCommitData commits = null;
+        try {
+            commits = stashService.getCommits(issueKey);
+            if (commits == null || commits.getFileDiffs() == null || commits.getFileDiffs().size() == 0) 
+            {
+                showResponse (resp, "jiraqahelper.tabpanel.error.nocommits", null, false);
+                return;
+            }
+            // Get the hints for this set of commits.
+            List<Hint> hints = hintService.getHints(rules, commits, stashService);
+            if (hints == null || hints.size() == 0)
+            {
+                showResponse (resp, "jiraqahelper.tabpanel.error.nohints", null, false);
+                return;
+            }
+            else
+            {
+                for (Hint hint : hints)
+                {
+                    showHint (resp, hint);
+                }
+                return;
+            }
+        } 
+        catch (StashAuthenticationRequiredException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.authrequired", e.getUrl(), true);
+        } 
+        catch (StashConnectionFailedException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.connectionfailed", e.getUrl(), true);
+        } 
+        catch (StashReadResponseFailedException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.readresponse", e.getUrl(), true);
+        } 
+        catch (NoStashAppLinkException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.nostashapplink", null, true);
+        } 
+        catch (InvalidStashJsonException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.badjson", null, true);
+        } 
+        catch (StashTimeoutException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.timeout", null, true);
+        } 
+        catch (StashException e) 
+        {
+            showResponse (resp, "jiraqahelper.tabpanel.error.unknown", null, true);
+        }
+    }
+    
+    private void showResponse (HttpServletResponse resp, String i18nKey, String arg, boolean isError) throws IOException
+    {
+        // todo: move out to a template 
+        // todo: XSS
+        PrintWriter writer = resp.getWriter();
+        writer.write("<html><body>");
+        writer.write(i18n.getText (i18nKey, arg)); 
+        writer.write("<br>");
+        writer.write("</body></html>");
+    }
+    
+    private void showHint (HttpServletResponse resp, Hint hint) throws IOException
+    {
+        // todo: move out to a template 
+        // todo: XSS
+        // todo: display commit details, specificity
+        PrintWriter writer = resp.getWriter();
+        writer.write("<html><body>");
+        writer.write(hint.getRule().getName() + " - " + hint.getRule().getDescription()); 
+        writer.write("<br>");
+        for (FileDiff fileDiff : hint.getFileDiffs())
+        {
+            writer.write("&nbsp;&nbsp;&nbsp;&nbsp;<a href=\"" + fileDiff.getUrl() + "\">" + fileDiff.getFullPath() + "</a><br>");
+        }
+        writer.write("</body></html>");
+    }    
+}

File src/main/java/com/atlassianqa/jiraqahelper/ui/HelperTabPanel.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import java.util.List;
-
-import com.atlassian.crowd.embedded.api.User;
-import com.atlassian.jira.issue.Issue;
-import com.atlassian.jira.issue.RendererManager;
-import com.atlassian.jira.plugin.issuetabpanel.GetActionsReply;
-import com.atlassian.jira.plugin.issuetabpanel.GetActionsRequest;
-import com.atlassian.jira.plugin.issuetabpanel.IssueAction;
-import com.atlassian.jira.plugin.issuetabpanel.IssueTabPanel2;
-import com.atlassian.jira.plugin.issuetabpanel.IssueTabPanelModuleDescriptor;
-import com.atlassian.jira.plugin.issuetabpanel.ShowPanelReply;
-import com.atlassian.jira.plugin.issuetabpanel.ShowPanelRequest;
-import com.atlassian.jira.security.Permissions;
-
-import com.atlassian.jira.component.ComponentAccessor;
-import com.atlassianqa.jiraqahelper.services.commits.IssueCommitData;
-import com.atlassianqa.jiraqahelper.services.hints.Hint;
-import com.atlassianqa.jiraqahelper.services.hints.HintService;
-import com.atlassianqa.jiraqahelper.services.rules.Rule;
-import com.atlassianqa.jiraqahelper.services.rules.RuleService;
-import com.atlassianqa.jiraqahelper.services.settings.PluginSettingsService;
-import com.atlassianqa.jiraqahelper.services.stash.InvalidStashJsonException;
-import com.atlassianqa.jiraqahelper.services.stash.NoStashAppLinkException;
-import com.atlassianqa.jiraqahelper.services.stash.StashAuthenticationRequiredException;
-import com.atlassianqa.jiraqahelper.services.stash.StashConnectionFailedException;
-import com.atlassianqa.jiraqahelper.services.stash.StashException;
-import com.atlassianqa.jiraqahelper.services.stash.StashReadResponseFailedException;
-import com.atlassianqa.jiraqahelper.services.stash.StashService;
-import com.atlassianqa.jiraqahelper.services.stash.StashTimeoutException;
-
-import java.util.ArrayList;
-
-public class HelperTabPanel implements IssueTabPanel2
-{
-    protected IssueTabPanelModuleDescriptor descriptor;
-    private StashService stashService;
-    private RuleService ruleService;
-    private HintService hintService;
-    private PluginSettingsService pluginSettingsService;    
-    private RendererManager rendererManager;
-    
-    public HelperTabPanel (StashService stashService, HintService hintService, RuleService ruleService, PluginSettingsService pluginSettingsService, RendererManager rendererManager)
-    {
-        this.stashService = stashService;
-        this.ruleService = ruleService;
-        this.hintService = hintService;
-        this.pluginSettingsService = pluginSettingsService;
-        this.rendererManager = rendererManager;
-    }
-    
-    public void init (IssueTabPanelModuleDescriptor descriptor) {
-        this.descriptor = descriptor;
-    }
-            
-    @SuppressWarnings({ "unchecked", "rawtypes" })
-    public List getActions (Issue issue, User user) 
-    {
-        // Create a list of items to display to the user on the tab panel and start with the header.
-        ArrayList<IssueAction> actions = new ArrayList<IssueAction> ();
-        actions.add (new HelperTabPanelHeaderAction (descriptor, issue.getKey()));
-
-        // Get the hints from the plugin settings for this project.
-        List<Rule> rules = ruleService.getRulesForProject (issue.getProjectObject().getKey());
-        if (rules == null || rules.size() == 0) {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.norules", null, true));
-            return actions;
-        }
-
-        // Get the commits from Stash and handle all the things that could go wrong.
-        IssueCommitData commits = null;
-        try {
-            commits = stashService.getCommits(issue.getKey());
-            if (commits == null || commits.getFileDiffs() == null || commits.getFileDiffs().size() == 0) 
-            {
-                actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nocommits", null, false));
-                return actions;
-            }
-            // Get the hints for this set of commits.
-            List<Hint> hints = hintService.getHints(rules, commits, stashService);
-            if (hints == null || hints.size() == 0)
-            {
-                actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nohints", null, false));
-            }
-            else
-            {
-                for (Hint hint : hints)
-                {
-                    actions.add (new HelperTabPanelHintAction (descriptor, rendererManager, issue, hint));
-                }
-            }
-        } 
-        catch (StashAuthenticationRequiredException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.authrequired", e.getUrl(), true));
-        } 
-        catch (StashConnectionFailedException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.connectionfailed", e.getUrl(), true));
-        } 
-        catch (StashReadResponseFailedException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.readresponse", e.getUrl(), true));
-        } 
-        catch (NoStashAppLinkException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.nostashapplink", null, true));
-        } 
-        catch (InvalidStashJsonException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.badjson", null, true));
-        } 
-        catch (StashTimeoutException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.timeout", null, true));
-        } 
-        catch (StashException e) 
-        {
-            actions.add (new HelperTabPanelErrorAction (descriptor, "jiraqahelper.tabpanel.error.unknown", null, true));
-        }
-        return actions;
-    }
-    
-    public boolean showPanel (Issue issue, User remoteUser) 
-    {
-        // Show the tab panel...
-        // ... if the plugin is configured to let us see it...
-        if (remoteUser != null && pluginSettingsService.isUserAllowed(remoteUser.getName()))
-        {
-            // ... and if the current user has permission to see the Source tab...
-            if (ComponentAccessor.getPermissionManager().hasPermission(Permissions.VIEW_VERSION_CONTROL, issue, remoteUser))
-            {
-                // ... and an applink to Stash exists...
-                if (stashService.isConfigured ())
-                {
-                    // ... and if rules have been configured for this project.
-                    List<Rule> rules = ruleService.getRulesForProject (issue.getProjectObject().getKey());
-                    return (rules != null && rules.size() > 0);
-                }
-            }
-        }
-        return false;
-    }
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public GetActionsReply getActions(GetActionsRequest request) 
-    {
-        // If we're being called asynchronously, take the time to generate the full tab panel.
-        if (request.isAsynchronous())
-        {
-            return GetActionsReply.create (this.getActions (request.issue(), request.remoteUser()));
-        }
-        // If we're not, we're holding things up. Just serve up some javascript that loads the tab panel asynchronously.
-        ArrayList<IssueAction> actions = new ArrayList<IssueAction> ();
-        actions.add (new HelperTabPanelNonAjaxAction (descriptor, request.issue()));
-        return GetActionsReply.create (actions);  
-    }
-
-    @Override
-    public ShowPanelReply showPanel(ShowPanelRequest request) 
-    {
-        return ShowPanelReply.create(this.showPanel(request.issue(), request.remoteUser()));
-    }
-}

File src/main/java/com/atlassianqa/jiraqahelper/ui/HelperTabPanelErrorAction.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import java.util.Date;
-import com.atlassian.jira.plugin.issuetabpanel.*;
-import java.util.Map;
-import java.util.HashMap;
-
-public class HelperTabPanelErrorAction extends AbstractIssueAction
-{
-    private String errorKey;
-    private String url;
-    private boolean isError;
-
-    public HelperTabPanelErrorAction (IssueTabPanelModuleDescriptor descriptor, String errorKey, String url, boolean isError)
-    {
-        super(descriptor);
-        this.errorKey = errorKey;
-        this.url = url;
-        this.isError = isError;
-    }
-
-    public String getHtml()
-    {
-        HashMap<String, Object> params = new HashMap <String, Object> ();
-        params.put ("errorKey", errorKey);
-        params.put ("url", url);
-        params.put ("type", isError ? "error" : "info");
-        return descriptor.getHtml ("helper-tabpanel-error", params);
-    }
-
-    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
-    {
-    }
-
-    public Date getTimePerformed()
-    {
-        return new Date();
-    }
-
-    public boolean isDisplayActionAllTab()
-    {
-        return false;
-    }
-}
-

File src/main/java/com/atlassianqa/jiraqahelper/ui/HelperTabPanelHeaderAction.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import java.util.Date;
-import com.atlassian.jira.plugin.issuetabpanel.*;
-
-import java.util.Map;
-import java.util.HashMap;
-
-public class HelperTabPanelHeaderAction extends AbstractIssueAction
-{
-    private String issueKey;
-
-    public HelperTabPanelHeaderAction (IssueTabPanelModuleDescriptor descriptor, String issueKey)
-    {
-        super(descriptor);
-        this.issueKey = issueKey;
-    }
-
-    public String getHtml()
-    {
-        HashMap<String, Object> params = new HashMap <String, Object> ();
-        params.put ("issueKey", issueKey);
-        return descriptor.getHtml ("helper-tabpanel-header", params);
-    }
-
-    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
-    {
-    }
-
-    public Date getTimePerformed()
-    {
-        return new Date();
-    }
-
-    public boolean isDisplayActionAllTab()
-    {
-        return false;
-    }
-}
-

File src/main/java/com/atlassianqa/jiraqahelper/ui/HelperTabPanelHintAction.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import java.util.Date;
-import com.atlassian.jira.plugin.issuetabpanel.*;
-import com.atlassianqa.jiraqahelper.services.hints.Hint;
-import com.atlassian.jira.issue.Issue;
-import com.atlassian.jira.issue.RendererManager;
-import com.atlassian.jira.issue.fields.renderer.wiki.*;
-
-import java.util.Map;
-import java.util.HashMap;
-
-public class HelperTabPanelHintAction extends AbstractIssueAction
-{
-    private Hint hint;
-    private Issue issue;
-    private RendererManager rendererManager;
-
-    public HelperTabPanelHintAction (IssueTabPanelModuleDescriptor descriptor, RendererManager rendererManager, Issue issue, Hint hint)
-    {
-        super(descriptor);
-        this.hint = hint;
-        this.issue = issue;
-        this.rendererManager = rendererManager;
-    }
-
-    public String getHtml()
-    {
-        HashMap<String, Object> params = new HashMap <String, Object> ();
-        String hintHtml = rendererManager.getRenderedContent(AtlassianWikiRenderer.RENDERER_TYPE, hint.getRule().getDescription(), issue.getIssueRenderContext());
-        params.put ("hint", hint);
-        params.put ("hintHtml", hintHtml);
-        return descriptor.getHtml ("helper-tabpanel-hint", params);
-    }
-
-    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
-    {
-    }
-
-    public Date getTimePerformed()
-    {
-        return new Date();
-    }
-
-    public boolean isDisplayActionAllTab()
-    {
-        return false;
-    }
-}
-

File src/main/java/com/atlassianqa/jiraqahelper/ui/HelperTabPanelNonAjaxAction.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import java.util.Date;
-
-import com.atlassian.jira.issue.Issue;
-import com.atlassian.jira.plugin.issuetabpanel.*;
-
-import java.util.Map;
-import java.util.HashMap;
-
-public class HelperTabPanelNonAjaxAction extends AbstractIssueAction
-{
-    private Issue issue;
-    
-    public HelperTabPanelNonAjaxAction (IssueTabPanelModuleDescriptor descriptor, Issue issue)
-    {
-        super(descriptor);
-        this.issue = issue;
-    }
-
-    public String getHtml()
-    {
-        HashMap<String, Object> params = new HashMap <String, Object> ();
-        params.put("issueKey", issue.getKey());
-        return descriptor.getHtml ("helper-tabpanel-nonajax", params);
-    }
-
-    protected void populateVelocityParams(@SuppressWarnings("rawtypes") final Map map)
-    {
-    }
-
-    public Date getTimePerformed()
-    {
-        return new Date();
-    }
-
-    public boolean isDisplayActionAllTab()
-    {
-        return false;
-    }
-}
-

File src/main/java/com/atlassianqa/jiraqahelper/ui/UploadDataAction.java

-package com.atlassianqa.jiraqahelper.ui;
-
-import com.atlassian.jira.security.Permissions;
-import com.atlassian.jira.security.xsrf.RequiresXsrfCheck;
-import com.atlassian.jira.web.action.JiraWebActionSupport;
-import com.atlassianqa.jiraqahelper.services.rules.RuleService;
-import com.atlassianqa.jiraqahelper.services.settings.PluginSettingsService;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Scanner;
-
-import org.codehaus.jackson.JsonParseException;
-import org.codehaus.jackson.JsonParser;
-import org.codehaus.jackson.map.ObjectMapper;
-
-import webwork.multipart.MultiPartRequestWrapper;
-
-public class UploadDataAction extends JiraWebActionSupport 
-{
-    private static final long serialVersionUID = -4711771991379272266L;
-    private static final String SUCCESS = "success";
-    private static final String SECURITY_BREACH = "securitybreach";
-    private static final String FILE_FORM_KEY = "datafile";
-    private static final String USER_FORM_KEY = "allowedusers";
-    private PluginSettingsService pluginSettingsService;
-    private RuleService ruleService;
-    
-    public UploadDataAction (PluginSettingsService pluginSettingsService, RuleService ruleService) 
-    {
-        this.pluginSettingsService = pluginSettingsService;
-        this.ruleService = ruleService;
-    }
-
-    public String doDefault () throws Exception 
-    {
-        if (getLoggedInUser() == null || !isHasPermission(Permissions.ADMINISTER)) {
-            return SECURITY_BREACH;
-        }
-        return SUCCESS;
-    }
-    
-    public String getData ()
-    {
-        return pluginSettingsService.getRulesJson();
-    }
-    
-    public String getError ()
-    {
-        return pluginSettingsService.getErrorString();
-    }
-    
-    public String getAllowedUsers ()
-    {
-        return pluginSettingsService.getAllowedUsers();
-    }
-
-    @RequiresXsrfCheck
-    protected String doExecute () throws Exception 
-    {
-        if (getLoggedInUser() == null || !isHasPermission(Permissions.ADMINISTER)) 
-        {
-            return SECURITY_BREACH;
-        }
-        
-        // Store the new JSON rules data
-        MultiPartRequestWrapper wrapper = (MultiPartRequestWrapper) request;
-        File uploadedFile = wrapper.getFile(FILE_FORM_KEY);
-        if (uploadedFile == null)
-        {
-            // If there was no new file uploaded, leave the JSON as-is.
-        }
-        else
-        {
-            String dataFileContent = new Scanner(uploadedFile).useDelimiter("\\Z").next();
-            pluginSettingsService.setRulesJson (dataFileContent);
-            ruleService.refreshRulesFromSettings(pluginSettingsService);
-            pluginSettingsService.setErrorString (getValidationError(dataFileContent));
-        }
-        
-        // request.getParameter (USER_FORM_KEY) always returns null, so we use the array, even though we only want one value.
-        String [] allowedUsersArr = request.getParameterValues (USER_FORM_KEY);
-        String allowedUsers = null;
-        if (allowedUsersArr != null && allowedUsersArr.length > 0)
-        {
-            allowedUsers = allowedUsersArr[0];
-        }
-        pluginSettingsService.setAllowedUsers (allowedUsers);
-        
-        return SUCCESS;
-    }
-    
-    private String getValidationError (String json)
-    {
-        // Check that the JSON is valid and return an error string if not.
-        String error = null;
-        try 
-        { 
-            JsonParser parser = new ObjectMapper().getJsonFactory().createJsonParser(json); 
-            while (parser.nextToken() != null); 
-            return null;
-        } 
-        catch (JsonParseException jpe) 
-        { 
-            error = jpe.getMessage(); 
-        }
-        catch (IOException ioe) 
-        { 
-            error = ioe.getMessage();
-        }         
-        if (error == null) 
-        {
-            return null;
-        }
-        // The second line of the error message is not useful to end-users.
-        // Just take the first.
-        return error.split("\\n")[0];
-    }
-}

File src/main/resources/atlassian-plugin.xml

         <component key="ruleService" name="Rule Service" class="com.atlassianqa.jiraqahelper.services.rules.RuleServiceImpl">
             <interface>com.atlassianqa.jiraqahelper.services.rules.RuleService</interface>
         </component>
+
+        <resource type="i18n" name="jiraqahelper-i18n" location="i18n.JIRAQAHelper"/>	
+    
+    <!-- Stash-Specific UI Components -->
+    
+        <web-item name="JIRA QA Helper Admin Link" key="qa-helper-admin-link-stash" system="true" weight="49" section="atl.admin/admin-settings-section">
+            <label key="jiraqahelper.admin.adminlink" />
+            <link linkId="jiraqahelper_data">/secure/admin/UploadDataAction!default.jspa</link>
+        </web-item>
+
+        <servlet name="Test Servlet" key="qa-helper-test-stash" class="com.atlassianqa.jiraqahelper.stashui.HelperServlet">
+            <description>Test servlet for Stash</description>
+            <url-pattern>/qahelper</url-pattern>
+        </servlet>
     
     <!-- JIRA-Specific UI Components -->
     
             </actions>
         </webwork1>
     
-        <web-item name="JIRA QA Helper Admin Link" key="jira-security-example-verify-identity" system="true" weight="49" section="system.admin/globalsettings">
+        <web-item name="JIRA QA Helper Admin Link" key="qa-helper-admin-link-jira" system="true" weight="49" section="system.admin/globalsettings">
             <label key="jiraqahelper.admin.adminlink" />
             <link linkId="jiraqahelper_data">/secure/admin/UploadDataAction!default.jspa</link>
         </web-item>
             <context>jira.view.issue</context>
         </web-resource>    
 
-        <resource type="i18n" name="jiraqahelper-i18n" location="i18n.JIRAQAHelper"/>	
-    
 </atlassian-plugin>

File src/main/resources/i18n/JIRAQAHelper.properties

 jiraqahelper.tabpanel.error.norules = No rules have been configured. Please upload some via the configuration page.
 jiraqahelper.tabpanel.error.nocommits = There are no commits against this issue.
 jiraqahelper.tabpanel.error.nohints = Nothing to suggest. You''re on your own!
-jiraqahelper.tabpanel.error.nostashapplink = No Stash applink has been configured. Right now this plugin only recognises applinks to stash.atlassian.com .
+jiraqahelper.tabpanel.error.nostashapplink = No Stash applink has been configured. Please create an application link to a Stash server and try again.
 jiraqahelper.tabpanel.error.badjson = Could not parse the JSON returned by Stash.
 jiraqahelper.tabpanel.error.timeout = Connection timed out while retrieving data from Stash.
 jiraqahelper.tabpanel.error.unknown = An unknown problem has occurred while attempting to pull data from Stash. Sorry.