Google Chat Bot. Інтеграція з Jira. Частина II

У попередній частині ми вже додали до бота інтеграцію з Jira. У цій частині ми внесемо зміни до того функціонала, та попрацюємо над зворотньої інтеграцією — від Jira до Google Apps Script.

За моїм задумом, у випадку оновлення issue (редагування або зміни статусу) ми будемо відправляти до треду оновленні дані та оновлювати картку.

Але почнемо з далеку, з далекого далеку: нам треба зробити так, щоб повідомлення до чату було відправлено не від імені користувача, а від самого бота, бо інакше ми потім не зможемо оновити картку без участі користувача.

Для цього нам треба буде зробити сервісний акаунт.

Підготовка сервісного акаунту

Для створення сервісного акаунту вам слід перейти до сторінки налаштувань вашого проєкту, а потім тицнути «Create Service Account»:

Create Service Account

Далі, перейдіть до налаштувань цього акаунту, та створіть новий JSON ключ:

Private JSON Key

Після цього, ви отримаєте відповідний JSON файл, який буде виглядати наступним чином:

{
  "type": "service_account",
  "project_id": "bender-20",
  "private_key_id": "-----",
  "private_key": "-----BEGIN PRIVATE KEY-----\nAAAAAA\n-----END PRIVATE KEY-----\n",
  "client_email": "-----@-----.iam.gserviceaccount.com",
  "client_id": "-----",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/-----%40-----.iam.gserviceaccount.com",
  "universe_domain": "googleapis.com"
}

Вам потрібно буде перенести до Properties вашого бота декілька полів:

Apps Script Properties

Все, сервісний акаунт створено та ключі додано. Поїхали далі.

Доречі, ви можете використовувати створений акаунт якщо вам потрібно буде надати дозвіл на будь який ресурс у межах Google документів. Це так, на майбутнє.

OAuth

Наступний крок — створення OAuth Consent Screen:

Create OAuth Consent Screen

Якщо ви розробляєте для Google Workspace, то у вас буде можливість обрати опцію Internal, таким чином ви обмежити використання вашого додатку та він буде «лише для своїх»

Додайте всю необхідну інформацію:

OAuth Consent Screen

На кроці додавання скоупів, я додав лише наступні:

OAuth Scopes

Після цього нас чекають ще трошки «напрягів» — для того, щоб відправити повідомлення до чату слід використовувати наступну нотацію для виклику метода Chat.Spaces.Messages.create():

Chat.Spaces.Messages.create(
  {'text': 'Hello world!'},
  event.space.name,
  {},
  {'Authorization': `Bearer ${serviceToken}`}
);

Але щоб отримати отой serviceToken нам слід підключити бібліотеку OAuth до нашого чату використовуючи ідентифікатор:

1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

Створимо функцію getServiceAccessToken():

/**
 * Get Access Token by service credentials
 *
 * Examples of usage:
 * 
 *  getServiceAccessToken('messages', ['https://www.googleapis.com/auth/chat.messages.create']);
 *  getServiceAccessToken('spaces', ['https://www.googleapis.com/auth/chat.spaces.readonly']);
 */
function getServiceAccessToken(serviceName = 'messages', scopes = []) {
  const scriptProperties = PropertiesService.getScriptProperties()

  const service = OAuth2.createService(serviceName)
    .setTokenUrl('https://oauth2.googleapis.com/token')
    .setPrivateKey(scriptProperties.getProperty('PRIVATE_KEY').replace(/\\n/g, '\n')) // lifehack ^_^
    .setIssuer(scriptProperties.getProperty('CLIENT_EMAIL'))
    .setPropertyStore(scriptProperties)
    .setScope(scopes.join(' '));

  if (!service.hasAccess()) {
    Logger.log('Authentication error: %s', service.getLastError());
    return null;
  }

  return service.getAccessToken();
}

Все, збираємо до кучі:

const serviceToken = getServiceAccessToken('messages', ['https://www.googleapis.com/auth/chat.messages.create']);

Chat.Spaces.Messages.create(
  {'text': 'Hello world!'},
  event.space.name,
  {},
  {'Authorization': `Bearer ${serviceToken}`}
);

Результат:

Message in chat

Повертаємось до редагування actionNewIssue.gs, та замінимо створення повідомлення вже використовуючи сервісний токен:

/**
 * @param {Object} event the event object from Google Chat
 */
function actionNewIssue(event) {
  //
  // ... the code was cropped
  // 

  try {
    const card = cardIssue(event, issue)
    const serviceToken = getServiceAccessToken('messages', ['https://www.googleapis.com/auth/chat.messages']);
    const message = Chat.Spaces.Messages.create(
      card, 
      event.space.name,
      {},
      {'Authorization': `Bearer ${serviceToken}`}
    );

    // Extracting the thread name from the response
    // Assuming ID is spaces/AAAAAAAA/threads/BBBBBBBB
    const [ , spaceId, , threadAndMessageId] = message.thread.name.split('/');

    const threadUrl = `https://chat.google.com/room/${spaceId}/${threadAndMessageId}/${threadAndMessageId}`;

    Logger.log(`New thread ${threadUrl}`)

    let updateData = {
      "fields": {}
    }

    updateData.fields[jiraApi.customField] = threadUrl

    jiraApi.updateIssue(response.key, updateData)
  } catch (err) {
    Logger.log('Failed to create message with error %s', err.message);
  }

  return OK()
}

На цьому ми завершили роботу по створенню issue

Web Application

Далі, нам треба додати можливість викликати нашого бота з зовні, для цього слід створити новий Deployment:

Після цього у вас з’явиться Web app URL, ви можете навіть спробувати відкрити посилання, але вас буде чекати помилка:

Error

Так, нас чекають нові методи для обробки запитів ззовні, зустрічайте doGet() та doPost():

/**
 * GET request to Web app URL
 */
function doGet(event) {
  Logger.log(`WebHook doGet()`)

  return HtmlService.createHtmlOutput("GET request processed");
}
/**
 * POST request to Web app URL
 */
function doPost(event) {
  Logger.log(`WebHook doPost()`)

  return HtmlService.createHtmlOutput("POST request processed");
}

Спробуйте тепер постукати до того Web URL, ви отримаєте відповідь від вашого бота. Але нас цікавить саме URL, скопіюйте його, він нам знадобиться.

Автоматизація в Jira

Настав час повернутися до Jira та налаштувати автоматизацію, для цього у вас повинні бути адмінські права до проєкту. Перейдіть до Automation та створіть правила які будуть виконуватися під час роботи над issue у Jira. Я додав 3 простих правила:

  • коли хтось ассайнить issue
  • коли змінюється статус
  • коли редагують Summary або Description

Для цього нам якраз стане у нагоді URL до Web Application, але ми трошки змінемо його відповідно до призначення кожного правила:

Так, так, ми додали параметр action до URL, і тепер можемо побудувати логіку виходячи з нього:

/**
 * POST request to Web app URL
 */
function doPost(event) {
  const action = event.parameter.action ? event.parameter.action : null

  Logger.log(`WebHook doPost("${action}")`)

  if (!event.postData || !event.postData.contents) {
    Logger.log(`POST data is empty`)
  }

  const data = JSON.parse(event.postData.contents);

  if (!data) {
    Logger.log(`POST data is invalid`)
  }

  try {
    // You can now call your bot method based on event type
    switch (action) {
      case 'assigned':
        // ...
        break;
      case 'updated':
        // ...
        break;
      case 'transitioned':
        // ...
        break;
      default:
        Logger.log("Invalid action")
        break;
    }
  } catch (e) {
    return HtmlService.createHtmlOutput(`POST request processed with error: ${e}`);
  }

  return HtmlService.createHtmlOutput("POST request processed");
}

Звірніть увагу, що я також вказав значення «Webhook body» як «Issue Data»!

Відправка відповіді у тред

Тепер починається саме цікаве — нам треба відправити повідомлення як відповідь до початкового повідомлення, посилання на яке ми зберегли у Custom Field. Почнемо звісно з того, що дістанеме те посилання, та отримаємо з нього ідентифікатор потрібного треда (цей код я додам до doPost.gs):

const scriptProperties = PropertiesService.getScriptProperties();

// JIRA threadUrl
const threadUrl = data.fields[scriptProperties.getProperty('JIRA_CUSTOM_FIELD')]

const parts = threadUrl.split('/'); // Split the URL into parts by '/'
const spaceId = parts[4]; // The space ID is expected to be the fifth part
const threadId = parts[5]; // The thread ID is expected to be the sixth part

Давайте зробимо окрему функцію, яка буде відправляти повідомлення до треду:

/**
 * @param {String} spaceId the ID of the space
 * @param {String} threadId the ID of the thread
 * @param {Object} issue the issue data from JIRA
 */
function hookAssigned(spaceId, threadId, issue) {

  Logger.log(`Send update message to space "${spaceId}" to thread "${threadId}"`)

  const space = `spaces/${spaceId}`
  const thread = `spaces/${spaceId}/threads/${threadId}`

  let message = {
    "text": "Assigned",
    "thread": {
      "name": thread
    }
  }

  const serviceToken = getServiceAccessToken('messages', ['https://www.googleapis.com/auth/chat.messages']);

  return Chat.Spaces.Messages.create(
    message,
    space,
    { "messageReplyOption": "REPLY_MESSAGE_OR_FAIL" },
    { "Authorization": `Bearer ${serviceToken}` }
  );
}

Зверніть увагу на рядок 16, таким чином слід вказати до якого треду треба відправити відповідь, а також на рядок 25, бо без цього параметру до треду теж не дістатися. Додамо виклик цієї функції до doPost.gs та спробуємо заасайнити таск на себе:

У коді бота буде цікавіше приклад відповіді у тред, але то вже самі подивитесь

Оновлення картки

Тепер нам треба ще оновити безпосередньо саму картку яку ми відправляли до спейсу, для цього не так багато треба — взяти дані з Jira, знову згенерувати оновлену картку, та відправити її назад до чату:

/**
 * @param {String} spaceId the ID of the space
 * @param {String} threadId the ID of the thread
 * @param {Object} issue the issue data from JIRA
 */
function hookUpdated(spaceId, threadId, issue) {

  Logger.log(`Update the card relative to issue "${issue.key}"`)

  const serviceToken = getServiceAccessToken('messages', ['https://www.googleapis.com/auth/chat.messages']);

  const name = `spaces/${spaceId}/messages/${threadId}.${threadId}`

  let message = Chat.Spaces.Messages.get(name, {}, { "Authorization": `Bearer ${serviceToken}` })

  message = Object.assign({}, message, cardIssue(false, issue))

  return Chat.Spaces.Messages.update(message, name, { "updateMask": "cardsV2" }, { "Authorization": `Bearer ${serviceToken}` })
}

Невеличке пояснення, у рядку 14 ми отримуємо всю-всю картку зі спейсу, у 16 рядку — генеруємо оновлену картку по новим даним з Jira, у рядку 18 — оновлюємо картку, обов’язково вказавши потрібну нам updateMask = cardsV2.

Аналогічно треба буде зробити для редагування та зміни статусу issue, в мене в результаті вийшов ось такий doPost.gs:

/**
 * POST request to Web app URL
 */
function doPost(event) {
  const action = event.parameter.action ? event.parameter.action : null

  Logger.log(`WebHook doPost("${action}")`)

  if (!event.postData || !event.postData.contents) {
    Logger.log(`POST data is empty`)
  }

  const data = JSON.parse(event.postData.contents);

  if (!data) {
    Logger.log(`POST data is invalid`)
  }

  const scriptProperties = PropertiesService.getScriptProperties();

  // JIRA threadUrl
  const threadUrl = data.fields[scriptProperties.getProperty('JIRA_CUSTOM_FIELD')]

  if (!threadUrl || threadUrl.length === 0) {
    Logger.log(`Invalid thread URL for issue "${data.key}"`)
    return HtmlService.createHtmlOutput(`POST request processed: Invalid thread URL`);
  }

  try {
    const parts = threadUrl.split('/'); // Split the URL into parts by '/'
    const spaceId = parts[4]; // The space ID is expected to be the fifth part
    const threadId = parts[5]; // The thread ID is expected to be the sixth part

    // You can now call your bot method based on event type
    switch (action) {
      case 'assigned':
        hookUpdated(spaceId, threadId, data)
        hookAssigned(spaceId, threadId, data)
        break;
      case 'updated':
        hookUpdated(spaceId, threadId, data)
        break;
      case 'transitioned':
        hookUpdated(spaceId, threadId, data)
        hookTransitioned(spaceId, threadId, data)
        break;
      default:
        Logger.log("Invalid action")
        break;
    }
  } catch (e) {
    return HtmlService.createHtmlOutput(`POST request processed with error: ${e}`);
  }

  return HtmlService.createHtmlOutput("POST request processed");
}

Діаграма послідовності

Google Chat and Jira integration

#
# https://sequencediagram.org/
#
title Google Chat + Jira 
 
actor "User" as U
materialdesignicons F0822 "Chat" as C
materialdesignicons F167A "Apps Script" as S
materialdesignicons F0303 "JIRA" as J
actor "Developer" as D

U->C:run /issue
activate C
C->S:call slashIssue()
activate S
S->C:return dialogIssue() 
deactivate S
note over U,C: [ Summary ]\n\n[ Description ]\n\n    [  Send  ]
deactivate C

space -3
C->S:call actionNewIssue()
activate S
S->J: create issue
activate J
J->S: issue key
deactivate J
S->J: get issue
activate J
J->S: issue data
deactivate J
S->C: call create()
deactivate S
 
note over U,C: Summary\n\nDescription...\n\n[ JIRA ]

space -3
C->S: link to thread
space -3
S->J: update issue

space -10
create D

D->J: — assign\n— update\n— comment\n— change status

J->S: issue data to webhook

space -3
S->C: message to thread
space -2
S->C: update card

Домашнє завдання

Розібратися як створити web-hook для того, щоб додавати коментарі з Jira до треду у Google Chat.

Я лише дам підказку, що для автоматизації краще використовувати «custom data» з можливостями «smart values»:

{
  "key": "{{issue.key}}",
  "fields": {
    "summary": "{{issue.fields.summary.jsonEncode}}",
    "description": "{{issue.fields.description.jsonEncode}}",
    "customfield_14033": "{{issue.fields.customfield_14033}}"
  },
  "comment": {
    "body": "{{issue.comments.last.body.jsonEncode}}",
    "author": {
      "displayName": "{{issue.comments.last.author.displayName}}",
      "name": "{{issue.comments.last.author.name}}"
    }
  }
}

Source Code

Код бота доступний на GitHub, реліз 6.0.0 відповідає коду з цієї статті.

Bender, реліз 6.0.0
Bender, білд 6.0.0

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.