What is a plug-in for JIRA cloud
Atlassian accepts Cloud plugin in the form of Atlassian Connect Framework applications, which consist on a series of endpoints that serve various iframes populated in their UI along with some webhooks. All the capabilities of a plug-in are declared in a JSON file used by JIRA or their other apps to “install” the plugin.
Since it is mainly serving content, if our infrastructure already consists of Go services we will naturally want to add the plug-in to that same existing infrastructure.
We, at ShiftLeft recently found this particular requirement
and created a Go Framework for said task
as described in this post.
Turns out that with all the pieces in place, it is easier than one would thing to create one of these.
Here is a commented sample of a very basic plugin (this is functional, you just need a public URL that serves it over SSL on the port 443)
A sample implementation
First, the mandatory imports, which everybody excludes from their posts but I find so much easier to read the post if you have them
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path"
"strconv"
"github.com/ShiftLeftSecurity/atlassian-connect-go/handling"
"github.com/ShiftLeftSecurity/atlassian-connect-go/storage"
"github.com/gorilla/mux"
)
Then, we will define a storage, this one is a toy one that reads and writes from files in the FS, one should never use it in production
// Storage, this is a barebones and really bad implementation of our required storage.
type cheapAndNasstyStorage struct {
}
const storagePath = "cheapstorage"
func (c *cheapAndNasstyStorage) SaveJiraInstallInformation(j *storage.JiraInstallInformation) error {
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return fmt.Errorf("creating data storage (aka, the folder): %w", err)
}
// in real world this should be validated
f, err := os.OpenFile(path.Join(storagePath, j.ClientKey), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.ModePerm)
if err != nil {
return fmt.Errorf("opening file for storage: %w", err)
}
defer f.Close()
if err := json.NewEncoder(f).Encode(j); err != nil {
return fmt.Errorf("writing jira install information: %w", err)
}
return nil
}
func (c *cheapAndNasstyStorage) JiraInstallInformation(clientKey string) (*storage.JiraInstallInformation, error) {
p := path.Join(storagePath, clientKey)
if _, err := os.Stat(p); err != nil {
return nil, nil
}
f, err := os.Open(p)
if err != nil {
return nil, fmt.Errorf("opening client data file: %w", err)
}
var jii storage.JiraInstallInformation
if err := json.NewDecoder(f).Decode(&jii); err != nil {
return nil, fmt.Errorf("decoding jira install info: %w", err)
}
return &jii, nil
}
The whole thing is done in a rather declarative way in the main function (called real main so it can return an error and be handled from the actual main)
func realMain() error {
We will be getting all of our values from the env, so this works as a more generic sample.
st := &cheapAndNasstyStorage{}
logger := log.New(os.Stdout, "JIRALTALK:", log.Ldate|log.Ltime|log.Lshortfile)
pluginURL := os.Getenv("PLUGIN_URL")
pluginKey := os.Getenv("PLUGIN_KEY")
pluginName := os.Getenv("PLUGIN_NAME")
pluginDescription := os.Getenv("PLUGIN_DESCRIPTION")
vendorName := os.Getenv("VENDOR_NAME")
vendorURL := os.Getenv("VENDOR_URL")
The very first step is instantiating a handling.Plugin
which will define the base features of our plugin
// Instantiate a plugin with the necessary data for a simple version.
plugin := handling.NewPlugin(pluginName,// The human readable name
pluginDescription, // The human readable description
pluginKey, // A unique (as in the whole world or at least server) key
pluginURL, // The URL where we will host this plugin
"", // the relative path where this plugin endpoints are served (ie if you serve this under a subpath of your API)
st, // our storage
logger, // a default go logger.
[]string{
"READ",
"WRITE",
"ACT_AS_USER",
"ADMIN"}, // The scopes/permissions we want from JIRA (users will get prompted for these)
handling.Vendor{
Name: vendorName,
URL: vendorURL,
}) // Information about US or who we do this plugin in behalf of
The order in which we add handlers is not particularly important but ill do it in an order that I find follows the progression in which they might be used, please go to the README of the framework for detailed links to atlassian docs on the particulars of accepted values, for the most common ones we included Go constants/structures.
The first handler to add, is one for the installed
event which will receive a POST
from JIRA upon
installation of our plugins, there are oter lifecycle events such as uninstalled
or disabled
.
// Add a handler for the Install event
err := plugin.AddLifecycleEvent(handling.LCInstalled, "/installed",
// jira install information is not filled as this is the first call the plugin ever gets
Notice we pass a special kind of func, derived from http.HandlerFunc
which acceptsa handle to the
storage.Store
and to the jira install information if any (most likely there will not be any for the
case of an install but there will be for the rest of the events)
func(_ *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
var jii storage.JiraInstallInformation
if err := json.NewDecoder(r.Body).Decode(&jii); err != nil {
logger.Printf("decoding jira install information from body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
logger.Printf("called installed for client: %s", jii.ClientKey)
// you could check if this is a returning client, for instance.
if err := st.SaveJiraInstallInformation(&jii); err != nil {
logger.Printf("something went wrong installing: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
if err != nil {
return fmt.Errorf("adding lifecycle event: %w", err)
}
issueFieldKey := os.Getenv("ISSUE_FIELD_KEY")
issueFieldDescription := os.Getenv("ISSUE_FIELD_DESCRIPTION")
issueFieldName := os.Getenv("ISSUE_FIELD_NAME")
The next we do is adding a field to an issue, hese typically are not editable nor show in any of the screens unless you programatically add them to screens/tabs. They are commonly used to store 3rd party info.
They can be added through code, but ideally one would define them here and then configure them in code.
err = plugin.AddJiraIssueField(handling.JiraIssueFields{
Description: handling.Description{
Value: issueFieldDescription,
},
Key: issueFieldKey,
Name: handling.Name{
Value: issueFieldName,
},
Type: "string",
})
if err != nil {
return fmt.Errorf("adding jira issue field: %w", err)
}
Next, we add a Webhook, for this example we will use jira:issue_updated
which will invoke this endpoint
every time something at all changes in an issue. Please notice that we can pass a path that is wrapped
in a handling.RoutePath
because we need to specify which request.Form
keys we can receive, refer
to the README for links to the various options for JIRA defined keys.
The hook receives a hit with every change, ther is not a possibility to receive information about the event itself other than project and item affected information, one would typically trigger a request to JIRA to expand this info using the sent data.
// Add a Web hook, this cofigures jira to call the passed handler when a given event is triggered
err = plugin.AddWebhook("jira:issue_updated",
handling.NewRoutePath("/api/issue_updated",
map[string]string{
"id": "{issue.key}",
"projectKey": "{project.key}"}),
func(jii *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
// Notice JiraInstallInfo is already provided
q := r.URL.Query()
issueID := q.Get("id")
projectKey := q.Get("projectKey")
logger.Printf("Issue KEY %s of Project %s changed somehow", issueID, projectKey)
})
if err != nil {
return fmt.Errorf("adding jira webhook: %w", err)
}
webPanelKey := os.Getenv("WEB_PANEL_KEY")
webPanelName := os.Getenv("WEB_PANEL_NAME")
webPanelURL := os.Getenv("WEB_PANEL_URL")
webPanelHandlerURL := os.Getenv("WEB_PANEL_HANDLER_URL")
Finally we will add a webPanel
these are panels located in various parts of the JIRA UI which will
be displayed as part of their UI but the content fetched from the passed URL. The container (first
parameter, doesn’t necesarily need to be webPanel
there are various accepted values for different
kinds of displays such as jiraProjectAdminTabPanels
which will place a section in the project admin
page which can be used to edit our own settings as part of JIRA UI)
Please read the next section about requirements for the content of these panels.
// Add a Panel, this will add a panel in the right of the default issue view which will display
// the contents of the webPanelURL
err = plugin.AddWebPanel("webPanels", handling.WebPanel{
Conditions: []handling.Conditions{{Condition: "user_is_logged_in"}},
Key: webPanelKey,
Location: "atl.jira.view.issue.right.context",
Name: handling.Name{
Value: webPanelName,
},
URL: webPanelURL,
Weight: 100,
})
if err != nil {
return fmt.Errorf("adding jira web panel: %w", err)
}
Once we have defined all necessary sections to be added, we invoke plugin.Router
which accepts an
optional *gorilla.Mux
and returns it (or a new one) with a subrouter for all the plugin paths, including the serving
of the atlassian-connect.json
file
router := plugin.Router(nil)
Finally, since panels are not automatically generated due their complex nature, we can add listeners for the
various panel URLs we defined. Panel contents are just basic HTML, the only important note is
that <script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
must be included as it provides a way for the loaded content to talk to the existing UI in jira (it uses
iframes I think)
// Add an extra handler, which will respond to the panel request and its response rendered in
// the jira section indicated by the pannel
router.Methods(http.MethodGet).Path(webPanelHandlerURL).HandlerFunc(
plugin.VerifiedHandleFunc(func(jii *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
logger.Printf("asked for field page")
if jii == nil {
logger.Println("received unauthenticated request request")
w.WriteHeader(http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
issueID := vars["issueID"]
projectKey := vars["projectKey"]
/// BEWARE, the <script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
// part is necessary or this will not work.
returnText := fmt.Sprintf(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="ap-local-base-url" content="%s">
<title>Demo Info</title>
</head>
<body class="aui-page-hybrid">
<section role="main">
<div class="sl-flow-column">
<div class="sl-flow-60">
<ul>
<li>Issue ID: %s</li>
<li>Project Key: %s</li>
</ul>
</div>
</div>
</section>
<script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
</body>
</html>`, pluginURL, issueID, projectKey)
w.Header().Add("content-length", strconv.Itoa(len(returnText)))
w.Header().Add("content-type", "text/html")
w.Write([]byte(returnText))
}))
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
pathTmpl, _ := route.GetPathTemplate()
methods, _ := route.GetMethods()
logger.Printf("Loaded Routes: %s %s", methods, pathTmpl)
return nil
})
return http.ListenAndServe(":9876", router)
}
func main() {
if err := realMain(); err != nil {
os.Exit(1)
}
}
A simple runner
I run this with the following Script
#!/bin/bash
export PLUGIN_URL="https://public.url.with.ssl"
export PLUGIN_KEY="unique.key.for.our.product"
export PLUGIN_NAME="Demo JIRA plugin"
export PLUGIN_DESCRIPTION="A plugin for a demo blog post"
export VENDOR_NAME="Horacio Duran"
export VENDOR_URL="https://perri.to"
export ISSUE_FIELD_DESCRIPTION="Horacio's Demo Content"
export ISSUE_FIELD_KEY="horacio-demo-content-field"
export ISSUE_FIELD_NAME="Horacio Content"
export WEB_PANEL_KEY="horacio-demo-info"
export WEB_PANEL_NAME="Horacio Demo Info"
export WEB_PANEL_URL="pages/horaciofield/{issue.id}/{project.key}"
export WEB_PANEL_HANDLER_URL="/pages/horaciofield/{issueID}/{projectKey}"
go build .
./jirademo