У попередній частині ми вже додали до бота інтеграцію з Jira. У цій частині ми внесемо зміни до того функціонала, та попрацюємо над зворотньої інтеграцією — від Jira до Google Apps Script.
За моїм задумом, у випадку оновлення issue (редагування або зміни статусу) ми будемо відправляти до треду оновленні дані та оновлювати картку.
Але почнемо з далеку, з далекого далеку: нам треба зробити так, щоб повідомлення до чату було відправлено не від імені користувача, а від самого бота, бо інакше ми потім не зможемо оновити картку без участі користувача.
Для цього нам треба буде зробити сервісний акаунт.
Підготовка сервісного акаунту
Для створення сервісного акаунту вам слід перейти до сторінки налаштувань вашого проєкту, а потім тицнути «Create Service Account»:
Далі, перейдіть до налаштувань цього акаунту, та створіть новий JSON ключ:
Після цього, ви отримаєте відповідний 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 вашого бота декілька полів:
Все, сервісний акаунт створено та ключі додано. Поїхали далі.
Доречі, ви можете використовувати створений акаунт якщо вам потрібно буде надати дозвіл на будь який ресурс у межах Google документів. Це так, на майбутнє.
OAuth
Наступний крок — створення OAuth Consent Screen:
Якщо ви розробляєте для Google Workspace, то у вас буде можливість обрати опцію Internal, таким чином ви обмежити використання вашого додатку та він буде «лише для своїх»
Додайте всю необхідну інформацію:
На кроці додавання скоупів, я додав лише наступні:
Після цього нас чекають ще трошки «напрягів» — для того, щоб відправити повідомлення до чату слід використовувати наступну нотацію для виклику метода 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}`} );
Результат:
Повертаємось до редагування 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, ви можете навіть спробувати відкрити посилання, але вас буде чекати помилка:
Так, нас чекають нові методи для обробки запитів ззовні, зустрічайте 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"); }
Діаграма послідовності
# # 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 відповідає коду з цієї статті.