Qué es un plug-in para JIRA cloud
Atlassian acepta plugins para Cloud en forma de aplicaciones del framework Atlassian Connect, que consisten en una serie de endpoints que sirven varios iframes que se muestran en su interfaz, junto con algunos webhooks. Todas las capacidades de un plug-in se declaran en un archivo JSON que JIRA u otras de sus apps usan para “instalar” el plugin.
Como básicamente se trata de servir contenido, si nuestra infraestructura ya está compuesta por servicios en Go, naturalmente vamos a querer sumar el plug-in a esa misma infraestructura existente.
En ShiftLeft nos encontramos hace poco con este requerimiento en particular y creamos un framework en Go para dicha tarea como se describe en este post.
Resulta que, con todas las piezas en su lugar, crear uno de estos es más fácil de lo que uno pensaría.
Acá va un ejemplo comentado de un plugin muy básico (es funcional, solo necesitás una URL pública que lo sirva por SSL en el puerto 443)
Una implementación de ejemplo
Primero, los imports obligatorios, que todo el mundo excluye de sus posts pero que a mí me resulta mucho más fácil leer el post si están.
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"
)
Después, vamos a definir un storage; este es uno de juguete que lee y escribe desde archivos en el FS, y nunca debería usarse en producción.
// 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
}
Todo se hace de manera bastante declarativa en la función main (que llamamos real main para que pueda devolver un error y manejarse desde el main verdadero).
func realMain() error {
Vamos a obtener todos nuestros valores desde el entorno, así esto funciona como un ejemplo más genérico.
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")
El primer paso de todos es instanciar un handling.Plugin, que va a definir las características base de nuestro 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
El orden en el que agregamos los handlers no es particularmente importante, pero yo lo voy a hacer en un orden que me parece que sigue la progresión en la que podrían usarse. Consultá el README del framework para enlaces detallados a la documentación de atlassian sobre las particularidades de los valores aceptados; para los más comunes incluimos constantes/estructuras de Go.
El primer handler que agregar es uno para el evento installed, que va a recibir un POST de JIRA al
instalarse nuestro plugin; hay otros eventos de ciclo de vida como uninstalled o 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
Notá que pasamos un tipo especial de func, derivado de http.HandlerFunc, que acepta un handle al
storage.Store y a la información de instalación de jira, si la hay (lo más probable es que no la haya para el
caso de una instalación, pero sí la va a haber para el resto de los eventos).
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")
Lo siguiente que hacemos es agregar un campo a un issue; estos por lo general no son editables ni se muestran en ninguna de las pantallas, a menos que los agregues programáticamente a las pantallas/pestañas. Se usan comúnmente para almacenar información de terceros.
Se pueden agregar por código, pero lo ideal sería definirlos acá y después configurarlos por código.
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)
}
Después agregamos un Webhook; para este ejemplo vamos a usar jira:issue_updated, que va a invocar este endpoint
cada vez que cambie algo en un issue. Notá que podemos pasar un path que va envuelto
en un handling.RoutePath porque necesitamos especificar qué claves de request.Form podemos recibir; consultá
el README para los enlaces a las distintas opciones de claves definidas por JIRA.
El hook recibe un llamado con cada cambio; no hay posibilidad de recibir información sobre el evento en sí más allá de la información del proyecto y del ítem afectado, así que uno típicamente dispararía una petición a JIRA para expandir esa info usando los datos enviados.
// 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")
Por último, vamos a agregar un webPanel; estos son paneles ubicados en varias partes de la interfaz de JIRA que se
mostrarán como parte de su UI, pero cuyo contenido se obtiene de la URL pasada. El contenedor (primer
parámetro) no necesariamente tiene que ser webPanel, hay varios valores aceptados para distintos
tipos de visualizaciones, como jiraProjectAdminTabPanels, que va a ubicar una sección en la página de administración del proyecto
que puede usarse para editar nuestros propios ajustes como parte de la UI de JIRA).
Leé la próxima sección sobre los requisitos para el contenido de estos paneles.
// 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)
}
Una vez que definimos todas las secciones necesarias a agregar, invocamos plugin.Router, que acepta un
*gorilla.Mux opcional y lo devuelve (o uno nuevo) con un subrouter para todos los paths del plugin, incluyendo el servido
del archivo atlassian-connect.json.
router := plugin.Router(nil)
Por último, como los paneles no se generan automáticamente debido a su naturaleza compleja, podemos agregar listeners para las
distintas URLs de panel que definimos. El contenido de los paneles es simplemente HTML básico; la única nota importante es
que <script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
debe incluirse, ya que provee una forma de que el contenido cargado se comunique con la UI existente en jira (usa
iframes, creo).
// 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)
}
}
Un runner simple
Yo corro esto con el siguiente 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