Develop Jira plugin with forge

Featured image

Judge a man by his questions rather than by his answers. — Voltaire1

Introduction. Link to heading

Jira Software allows teams to plan, assign, track, and manage work more easily.

Born in 2002 initially for bug and ticket monitoring only, it is written in Java, and the name is a truncation of Gojira, Japanese transliteration of Godzilla

In this article, we will create an application with the Forge development platform, for collecting and sending data to an external server, via a REST call.

Our plugin will contain:

  • a configuration page with data from the external server
  • functions that fetch the data from jira
  • an additional data form sending data to the external server
Tip

The entire project can be downloaded from here

  • There are various ways to install Jira apps on your environment.
  • If you prefer to download the code and publish the app from your environment use
    • npm install forge register forge deploy
  • You can monitor your apps and logs in the developer section, log in with your user, to find out which is which type forge whoami
    • Jira app logs, will never be visible in the browser console.

App initialization Link to heading

  • Install node and the cli of forge. GUIDE
  • initializes the app. GUIDE
    • forge create we choose the jira-admin-page-ui-kit template.
    • this choice is not binding, it is just the starting files structure
  • we edit the manifest.yml file to update the app name and icon
modules:
  jira:adminPage:
    - key: testplugincb-hello-world-admin-page
      function: main
      title: Test Plugin CB
      label: Description of your plugin
      icon: https://iCloudio.github.io/images/imgOnda.png
  • We install with forge deploy and forge install as described HERE
  • Let’s check the result, from the site, with the section “app” and then “manage app”

Configuration page Link to heading

Let’s immediately create a page to save the credentials of the external server, accessible to the administrator.

I followed the official example Issue Health, here is the commented js file

import ForgeUI, { render, AdminPage, Fragment, Form, useState, useEffect, SectionMessage, TextField } from '@forge/ui';
import { storage } from '@forge/api';

const App = () => {
    const STORAGE_KEY_PREFIX = "CB_CONFIGURATION_DATA";
    // inizializzo le variabili
    const [projectConfigState, setProjectConfigState] = useState(undefined);
    const [isConfigSubmitted, setConfigSubmitted] = useState(false);
    
    // questa funzione viene lanciata appena la pagina viene caricata, ed ogni volta che il valore del secondo parametro "cambia"
    // lo scopo è quello di caricare i dati salvati nel localStorage, se ci sono
    useEffect(async () => {
        const storageData = await storage.get(`${STORAGE_KEY_PREFIX}`);
        setProjectConfigState(storageData);
    }, [projectConfigState]);
    
    // sul submit della form, salviamo i dati nel localStorage del nostro plugin
    const onProjectConfigSubmit = async (serverConfig) => {
        await storage.set(`${STORAGE_KEY_PREFIX}`, serverConfig);
        setProjectConfigState(serverConfig);
        setConfigSubmitted(true);
    };

    // creazione grafica della form di inserimento e visualizzazione dati
    return (
        <Fragment>
            {isConfigSubmitted && <SectionMessage title="Configurazione Salvata" appearance="confirmation"/>}
            {<Form onSubmit={onProjectConfigSubmit} submitButtonText="Salva">
                <TextField label="Server URL" name="CB_CONF_SERVERURL" defaultValue={projectConfigState?.CB_CONF_SERVERURL} />
                <TextField label="WebApi Username" name="CB_CONF_USERNAME" defaultValue={projectConfigState?.CB_CONF_USERNAME} />
                <TextField label="WebApi Password" name="CB_CONF_PASSWORD" type='password' defaultValue={projectConfigState?.CB_CONF_PASSWORD} />
            </Form>}
        </Fragment>
    );
};

export const run = render(
    <AdminPage>
        <App />
    </AdminPage>
);

And after a forge deploy

Error
Using the Forge Storage API requires the “storage:app” scope permission-scope-require
Instead of manually adding in manifest.yml the scope permissions, ___launch forge lint --fix to assign permissions automatically.

Error
Error: Can’t resolve ‘@forge/api’
We didn’t list the package in the package.json so I use npm install @forge/api, otherwise npm install would have sufficed.

Since we have a new scope, in addition to running forge deploy we also need to run forge install --upgrade.

Here is the result!

jira plugin configuration page

Issue panel creation with custom form Link to heading

Now we want to add a new panel for each task, so let’s look in the list of components. The one that is right for us is Issue Panel.

First we add the new form inside the manifest.yml, my modules section will contain:

First we add the new module inside the manifest.yml, my modules section will contain:

  jira:adminPage:
    - key: testplugincb-hello-world-admin-page
      function: main
      title: Test Plugin CB
      label: description of your plugin
      icon: https://iCloudio.github.io/images/imgOnda.png
  jira:issuePanel:
    - key: issue-panel
      function: panel
      title: Create CB Report
      label: Form Data CB
      icon: https://iCloudio.github.io/images/imgOnda.png
      description: Description of operations performed by the plugin
  function:
    - key: main
      handler: index.run
    - key: panel
      handler: panel.run

I then proceed to create the panel.js file that contains:

import ForgeUI, { render, Fragment, Text, IssuePanel, Table, Cell, useEffect, Form, SectionMessage, DatePicker, Select, Option, useState, Toggle, TextArea, useProductContext, Button, TextField, Row } from '@forge/ui';
import api, { storage, route } from "@forge/api";

const App = () => {
  const STORAGE_KEY_PREFIX = "CB_CONFIGURATION_DATA";
  // useState is a UI kit hook we use to manage the form data in local state
  const [formState, setFormState] = useState();
  const [jiraUser, setJiraUser] = useState();
  const [issueData, setIssueData] = useState();
  const [storageData] = useState(async () => await storage.get(`${STORAGE_KEY_PREFIX}`));
  const [trasfertaVisible, setTrasfertaVisible] = useState(false);
  const [errorString, setErrorString] = useState();
  const [successString, setSuccessString] = useState();
  const { platformContext: { issueKey }, accountId } = useProductContext(); // Get the context issue key

  const selectTipoIntervento = [
    { label: "Esterno", value: "esterno", defaultSelected: false },
    { label: "Interno", value: "interno", defaultSelected: true },
    { label: "Teleassistenza", value: "teleassistenza", defaultSelected: false }
  ];

  // On start i collect logged jira user data
  useEffect(async () => {
    const response = await api.asUser().requestJira(route`/rest/api/3/myself`);
    await checkResponse('Jira API', response);
    setJiraUser(await response.json()); // se setto solamente una variabile, essa non viene aggiornata in grafica
  }, []);

  // On start, i collect context data (issue name and description, project name)
  useEffect(async () => {
    const response = await api.asApp().requestJira(route`/rest/api/2/issue/${issueKey}`);
    await checkResponse('Jira API', response);
    setIssueData((await response.json()).fields);
  }, []); // se metti qualcosa nell array in dipendenza, appena questa cambia il suo valore, questa function viene rieseguita

  // Jira Plugin is published on AWS, i needed to know the public ip of published plugin
  useEffect(async () => {
    const translateRespons1e = await api.fetch("https://api.db-ip.com/v2/free/self",
      { method: 'GET'}
    );
    await checkResponse('TEST API', translateRespons1e);
  }, []);

  const showHideTrasferta = () => { 
    setTrasfertaVisible(!trasfertaVisible);
  }

  // sulla submit del form, voglio creare anche un registro di lavoro Jira, e aggiorna il tracciamento temporale del progetto
  const createWorkLog = async (secondiWorkLog, dataInizio) => {
    var bodyData = `{"timeSpentSeconds": ${secondiWorkLog}, "started": "${dataInizio + "T03:00:00.000+0000"}"}`;
    const response = await api.asUser().requestJira(route`/rest/api/2/issue/${issueKey}/worklog`, {
      method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: bodyData
    });
    await checkResponse('CB API', response);
  }

  // Handles form submission, which is a good place to call APIs, or to set component state...
  const onSubmit = async (formData) => {
    // authenticazione e successivamente invio dati al nostro server
    const translateResponse = await api.fetch(storageData.CB_CONF_SERVERURL,
      { method: 'POST',  headers: {'Content-Type': 'application/json; charset=UTF-8'},
        body: JSON.stringify({
        "username": storageData.CB_CONF_USERNAME,
        "password": storageData.CB_CONF_PASSWORD,
      })
    });
    await checkResponse('CB API', translateResponse);

    setErrorString(undefined);
    setSuccessString(undefined);
    /** formData: { username: 'Username', products: ['jira'] }*/
    formData.CB_commessa = issueData?.project?.name.split('[').pop().split(']')[0];
    formData.CB_oggetto = "Rapportino d'intervento";
    formData.CB_user = jiraUser?.emailAddress;
    setFormState(formData); // aggiorno la variabile formData, con i dati inseriti dall utente nel form
    const secondiWorkLog = formData.CB_workTime * 60 * 60;
    createWorkLog(secondiWorkLog, formData.CB_executionDate);

    // post dati server... se ok:
    setSuccessString("Creazione rapportino eseguita.");
  };

  return (
    <Fragment>
      <Form onSubmit={onSubmit} onFinish={(values) => this.handleFormSubmit(values)} submitButtonText="Crea Rapportino">
            {<Table>
                <Row>
                  <Cell>
                    <DatePicker name="CB_executionDate" label="Data Intervento" defaultValue={new Date().toLocaleDateString('en-CA')} isRequired />
                  </Cell>
                  <Cell>
                    <TextArea name="CB_description" label="Descrizione attività" isRequired defaultValue={issueData?.description}/>
                  </Cell>
                </Row>
                <Row>
                  <Cell>
                    <TextField name="CB_workTime" label="Tempo Impiegato in ore" type="number" isRequired />
                  </Cell>
                  <Cell>
                    <Toggle name="CB_bpInAffiancamento" label="BP in affiancamento" />
                  </Cell>
                </Row>
                <Row>
                  <Cell>
                    <Select label="Tipo Intervento" name="CB_tipoIntervento" isRequired>
                      {selectTipoIntervento.map(el => <Option label={el.label} value={el.value} defaultSelected={el.defaultSelected}></Option>)}
                    </Select>
                  </Cell>
                 
                  <Cell>
                    {  !trasfertaVisible ?
                      <Button onClick={showHideTrasferta} text='Aggiungi trasferta'></Button> :
                      <Button onClick={showHideTrasferta} text='Rimuovi trasferta'></Button>}
                  </Cell>
                </Row>
                <Row>
                  <Cell>
                  {  trasfertaVisible ?
                    <TextField name="CB_oreViaggio" label="Ore viaggio" type="number" isRequired />
                    : null}
                  </Cell>
                  <Cell>
                  {  trasfertaVisible ?
                    <TextField name="CB_kmtotali" label="KM andata e ritorno" placeholder='Km percorsi andata e ritorno' isRequired type='number' />
                    : null}
                  </Cell>
                </Row>
            </Table>
          }
      </Form>
      {
        successString && successString != "" ?
          <SectionMessage appearance='confirmation'>
            <Text>{successString}</Text>
          </SectionMessage>
          : null
      }
      {
        errorString && errorString != "" ?
          <SectionMessage appearance='error'>
            <Text>{errorString}</Text>
          </SectionMessage>
          : null
      }

    </Fragment>
  );
};

export const run = render(
  <IssuePanel>
    <App />
  </IssuePanel>
);

/**
 * Checks if a response was successful, and log and throw an error if not.
 * Also logs the response body if the DEBUG_LOGGING env variable is set.
 * @param apiName a human readable name for the API that returned the response object
 * @param response a response object returned from `api.fetch()`, `requestJira()`, or similar
 */
 async function checkResponse(apiName, response) {
  if (!response.ok) {
    const message = `Error from ${apiName}: ${response.status} ${await response.text()}`;
    console.error(JSON.stringify(response));
    throw new Error(message);
  } else if (true) {
    console.warn(`Response from ${apiName}: ${await response.text()}`);
  }
}

With this we have concluded our app, next we will see how to publish it on the official store

If you found this article useful, or if you have ideas for the next one, please contact me!

Useful Links. Link to heading


  1. French philosopher, writer and poet, and one of the best-known authors of the Enlightenment. Read More ↩︎