Skip to content

Instantly share code, notes, and snippets.

@TheSharpieOne
Created July 17, 2018 16:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save TheSharpieOne/407894097830371dc2cee2e7cc3570d4 to your computer and use it in GitHub Desktop.
Save TheSharpieOne/407894097830371dc2cee2e7cc3570d4 to your computer and use it in GitHub Desktop.
A rocket.chat outgoing webhook integration which allow the user to create and manage on-prem/private JIRA instance tickets via rocket.chat messages.
/* exported Script */
/* globals console, _, s, HTTP */
const username = 'JIRA_SERVICE_ACCOUNT_USERNAME';
const password = 'JIRA_SERVICE_ACCOUNT_PASSWORD';
const baseJiraUrl = 'ON_PREM_JIRA_DOMAIN';
const baseApiUrl = `${baseJiraUrl}/rest/api/2`;
const apiTicketBase = `${baseApiUrl}/issue`;
const browseTicketBase = `${baseJiraUrl}/browse`;
// If behind corp proxy
// const npmRequestOptions = {
// rejectUnauthorized: false,
// strictSSL:false
// };
const auth = `${username}:${password}`;
// May need to be updated if even needed, maybe JIRA's API can take the string name directly?
const issueTypeMap = {
bug: '1',
task: '3',
feature: '2',
improvement: '4',
story: '7'
}
/** Global Helpers
*
* console - A normal console instance
* _ - An underscore instance
* s - An underscore string instance
* HTTP - The Meteor HTTP object to do sync http calls
*/
function getTicketLinkMarkup(ticket) {
return `[${ticket.toUpperCase()}](${browseTicketBase}/${ticket.toUpperCase()})`;
}
function formatApiError({response}) {
return `${response.data.errorMessages.length ? response.data.errorMessages.join('\n') : `\`\`\`${JSON.stringify(response.data.errors, null, 2)}\n\`\`\``}`;
}
function updateTicket(ticket, action, type, values) {
const ucTicket = ticket.toUpperCase();
if (!Array.isArray(values)) {
values = [values]
}
const valuesToAdd = values.map(value => ({[action]: value}));
if(valuesToAdd.length > 0) {
const data = { update: { [type]: valuesToAdd } };
const response = HTTP('PUT',`${apiTicketBase}/${ucTicket}`, {data, auth, npmRequestOptions});
const statusCode = response.error ? response.error.response.statusCode : response.result.statusCode;
if (response.error) console.log(response.error);
switch (statusCode) {
case 204: return `${type} ${action.substr(-1) === 'e' ? 'd' : 'ed'} to ${getTicketLinkMarkup(ucTicket)}.`
case 403: return `Service user does not have permission to ${action} ${type} to ${getTicketLinkMarkup(ucTicket)}.`
case 400: return `The bot had an issue and could not ${action} ${type}.`
default: return `Got ${statusCode} and the bot cannot handle that.`
}
}
}
function addStringItems(ticket, type, items) {
const ucTicket = ticket.toUpperCase();
if (!Array.isArray(items)) {
items = items.split(/,|\s/)
}
updateTicket(ticket, 'add', type, items.map(item => item.replace(/\s/g, '')).filter(item => !!item));
}
function removeStringItems(ticket, type, items) {
const ucTicket = ticket.toUpperCase();
if (!Array.isArray(items)) {
items = items.split(/,|\s/)
}
updateTicket(ticket, 'remove', type, items.map(item => item.replace(/\s/g, '')).filter(item => !!item));
}
function addWatchers(ticket, watchers){
const ucTicket = ticket.toUpperCase();
if (!Array.isArray(watchers)) {
watchers = watchers.split(/,|\s/)
}
const watchersToAdd = watchers.map(watcher => watcher.trim()).filter(watcher => !!watcher);
if(watchersToAdd.length > 0) {
const retVal = watchersToAdd.map(watcher => {
const response = HTTP('POST',`${apiTicketBase}/${ucTicket}/watchers`, {data: watcher, auth, npmRequestOptions});
if (response.error) {
console.log(response.error);
retVal.push(`The bot had an issue and could not add watchers.`);
}
const statusCode = response.error ? response.error.response.statusCode : response.result.statusCode;
switch (statusCode) {
case 204: return `${watcher} is now watching ${getTicketLinkMarkup(ucTicket)}.`
case 401: return `Service user does not have permission to add watcher to ${getTicketLinkMarkup(ucTicket)}.`
case 404: return `User \`${watcher}\` doesn't appear to exist.`
case 400: return `The bot had an issue and could not add watchers:\n${formatApiError(response.error)}`
default: return `Got ${statusCode} and the bot cannot handle that.`
}
});
return retVal.join('\n');
}
}
function removeWatchers(ticket, watchers){
const ucTicket = ticket.toUpperCase();
if (!Array.isArray(watchers)) {
watchers = watchers.split(/,|\s/)
}
const watchersToAdd = watchers.map(watcher => watcher.trim()).filter(watcher => !!watcher);
if(watchersToAdd.length > 0) {
const retVal = watchersToAdd.map(watcher => {
const response = HTTP('DELETE',`${apiTicketBase}/${ucTicket}/watchers?username=${watcher}`, {auth, npmRequestOptions});
if (response.error) {
console.log(response.error);
retVal.push(`The bot had an issue and could not remove watchers.`);
}
const statusCode = response.error ? response.error.response.statusCode : response.result.statusCode;
switch (statusCode) {
case 204: return `${watcher} is no longer watching ${getTicketLinkMarkup(ucTicket)}.`
case 401: return `Service user does not have permission to remove watcher from ${getTicketLinkMarkup(ucTicket)}.`
case 404: return `User \`${watcher}\` doesn't appear to exist.`
case 400: return `The bot had an issue and could not remove watchers:\n${formatApiError(response.error)}`
default: return `Got ${statusCode} and the bot cannot handle that.`
}
});
return retVal.join('\n');
}
}
function addComment(ticket, comment, request){
const ucTicket = ticket.toUpperCase();
if(comment) {
const data = { body: `[~${request.data.user_name}] via Rocket.Chat:\n${comment}` };
const response = HTTP('POST',`${apiTicketBase}/${ucTicket}/comment`, {data, auth, npmRequestOptions});
const statusCode = response.error ? response.error.response.statusCode : response.result.statusCode;
switch (statusCode) {
case 201: return `Comment added to ${getTicketLinkMarkup(ucTicket)}.`
case 403: return `Service user does not have permission to add comments to ${getTicketLinkMarkup(ucTicket)}.`
case 400: return `The bot had an issue and could not add the comment:\n${formatApiError(response.error)}`
default: return `Got ${statusCode} and the bot cannot handle that.`
}
}
}
function add(request) {
const [text, ticket, item, value] = request.data.text.match(/\s+([A-Za-z]{2,10}-\d+)\s+add\s+(labels?|comment|watchers?|time|worklog|components?)\s+(.*)/);
switch (item.toLowerCase()) {
case 'label':
case 'labels': return addStringItems(ticket, 'labels', value);
break;
case 'component':
case 'components': return addStringItems(ticket, 'components', value);
break;
case 'comment': return addComment(ticket, value, request);
break;
case 'watcher':
case 'watchers': return addWatchers(ticket, value);
break;
default: return `The ability to add ${item} has not been implemented _yet_.`;
}
}
function remove(request) {
const [text, ticket, item, value] = request.data.text.match(/\s+([A-Za-z]{2,10}-\d+)\s+remove\s+(labels?|watchers?|components?)\s+(.*)/);
switch (item.toLowerCase()) {
case 'label':
case 'labels': return removeStringItems(ticket, 'labels', value);
break;
case 'component':
case 'components': return removeStringItems(ticket, 'components', value);
break;
case 'watcher':
case 'watchers': return removeWatchers(ticket, value);
break;
default: return `The ability to add ${item} has not been implemented _yet_.`;
}
}
function getProjectId(projectKey) {
const response = HTTP('GET', `${baseApiUrl}/project/${projectKey.toUpperCase()}`, {auth, npmRequestOptions});
if (response.error) throw response.error;
return response.result.data.id;
}
function createTicket(request) {
const [text, projectKey, issuetypeKey = 'task', assignTo, summary, description] = request.data.text.match(/create\s+([^\s]+)\s+(bug|task|feature|improvement|story)?\s*(?:~([^\s]+))?\s*([^;]+);?\s*(.*)?/);
console.log(projectKey, assignTo, summary, description);
const project = {key: projectKey.toUpperCase()};
const fields = {
project,
summary,
description,
issuetype: {
id: issueTypeMap[issuetypeKey.toLowerCase()]
},
labels: ['from-rocketchat']
}
if (issuetypeKey.toLowerCase() !== 'task' && projectKey.toLowerCase() !== 'cm') {
fields.reporter = {name: request.data.user_name};
}
if (assignTo) {
fields.assignee = {name: assignTo};
}
const response = HTTP('POST',`${apiTicketBase}`, {data: {fields}, auth, npmRequestOptions});
const statusCode = response.error ? response.error.response.statusCode : response.result.statusCode;
switch (statusCode) {
case 201: return response.result.data.key
case 401: return `Service user does not have permission to create tickets for the \`${project.key}\` project.`
case 403: return `Service user does not have permission to create tickets for the \`${project.key}\` project.`
case 400: return `The bot had an issue and could not create the ticket:\n${formatApiError(response.error)}`
default: return `Got ${statusCode} and the bot cannot handle that.`
}
}
function help(request) {
const commands = request.data.text.split(' ');
let command;
if (commands.length > 2) {
command = commands[2];
}
switch(command){
case 'details': return [
'Get details about a specific ticket',
'**Usage**',
'```bash',
'jira <ticket-number>',
'jira details <ticket-number>',
'```',
'**Parameters**',
'• ticket-number: JIRA ticket number; e.g. `av-123`. Case insensitive; required',
'**Example**',
'`jira av-123`',
].join('\n');
case 'add': return [
'Add information to a specific ticket',
'**Usage**',
'```bash',
'jira <ticket-number> add <type> <value>',
'```',
'**Parameters**',
'• ticket-number: JIRA ticket number; e.g. `av-123`. Case insensitive; required',
'• type: Type of information you want to add to the ticket; e.g. Case insensitive; required; can be one of the following:',
'├ label',
'├ labels',
'├ component',
'├ components',
'├ watcher',
'├ watchers',
'└ comment',
'• value: The information you want to add. Depending on the type, this can be a comma/space separated list. Case insensitive; required',
'**Example**',
'`jira av-123 add label this-is-a-label, this-is-another-label this-is-a-third-label`',
'`jira av-123 add comment Testing completed in QA`',
'`jira av-123 add watcher esharp`',
].join('\n');
case 'remove': return [
'Remove information from a specific ticket',
'**Usage**',
'```bash',
'jira <ticket-number> remove <type> <value>',
'```',
'**Parameters**',
'• ticket-number: JIRA ticket number; e.g. `av-123`. Case insensitive; required',
'• type: Type of information you want to remove from the ticket; e.g. Case insensitive; required; can be one of the following:',
'├ label',
'├ labels',
'├ component',
'├ components',
'├ watcher',
'└ watchers',
'• value: The information you want to remove. Comma/space separated list. Case insensitive; required',
'**Example**',
'`jira av-123 remove label this-is-a-label, this-is-another-label this-is-a-third-label`',
'`jira av-123 remove watcher esharp`',
].join('\n');
case 'create': return [
'Create a new ticket',
'**Usage**',
'```bash',
'jira create <projectKey> <issueType> ~<assignTo> <title>; <description>',
'```',
'**Parameters**',
'• `projectKey`: JIRA project key; e.g. `av`. Case insensitive; required',
'• `issueType`: JIRA project issue type; e.g. `bug`. Case insensitive; optional, defaults to `task`. Must be one of the follow:',
'├ bug',
'├ task',
'├ feature',
'├ improvement',
'└ story',
'• `assignTo`: JIRA username to assign the ticket to; optional; If provided: Must start with `~`; Username must exist in JIRA.',
'• `title`: Title / summary of the ticket; required.',
'• `description`: Description / details of the ticket; optional; If provided: Must separate title from description using `;`.',
'**Examples**',
'`jira create av story ~esharp Add new thing; Add this thing to this place`',
'`jira create av Do this; This thing needs to be done`',
].join('\n');
default: return [
'**List of command**',
'• `details <ticket-number>`: Get ticket details',
'• `<ticket-number>`: alias for details',
'• `<ticket-number> add`: Add information to a specific ticket',
'• `<ticket-number> remove`: Remove information from a specific ticket',
'• `create <projectKey> <issueType> ~<assignTo> <title>; <description>`: create new ticket',
'• `help`: This help information',
'• `help <command>`: Get help for a specific command',
'**Note**',
`In order for any of these commands to work, the service user \`${username}\` will need permissions to the project associated with the ticket you are trying to gets details about or create.`,
].join('\n');
}
}
class Script {
/**
* @params {object} request
*/
prepare_outgoing_request({ request }) {
// request.params {object}
// request.method {string}
// request.url {string}
// request.auth {string}
// request.headers {object}
// request.data.token {string}
// request.data.channel_id {string}
// request.data.channel_name {string}
// request.data.timestamp {date}
// request.data.user_id {string}
// request.data.user_name {string}
// request.data.text {string}
// request.data.trigger_word {string}
// request.data.bot {boolean}
try {
if (request.data.text && request.data.user_name.toLowerCase() !== 'jira' && !request.data.bot && username && password) {
const commands = request.data.text.split(' ');
const command = commands[1] || '';
let ticket
switch(command.toLowerCase()) {
case "create": ticket = createTicket(request);
break;
case "help": ticket = help(request);
break;
case 'detail':
case 'details':
ticket = commands[2].match(/\b([A-Za-z]{2,10}-\d+)\b/g);
ticket = ticket && ticket[0];
break;
default:
const match = command.match(/\b([A-Za-z]{2,10}-\d+)\b/g);
ticket = match && match[0];
if (commands.length > 3) {
switch (commands[2]) {
case 'add': ticket = add(request);
break;
case 'remove': ticket = remove(request);
break;
}
}
break;
}
if (ticket && typeof ticket === 'string' && ticket.indexOf(' ') === -1) {
return {
command,
ticket,
url: `${apiTicketBase}/${ticket}`,
auth,
method: 'GET',
type: 'issue',
npmRequestOptions,
};
}
if (ticket && typeof ticket === 'string') {
return { message: { text: ticket } };
}
return ticket;
}
} catch(e) {
console.log('jiraticket error', e);
return {
error: {
success: false,
message: `${e.message || e} ${JSON.stringify(request.data)}`
}
};
}
}
/**
* @params {object} request, response
*/
process_outgoing_response({ request, response }) {
// request {object} - the object returned by prepare_outgoing_request
// response.error {object}
// response.status_code {integer}
// response.content {object}
// response.content_raw {string/object}
// response.headers {object}
if (response.error) {
let msg;
switch (response.status_code) {
case 403: msg = `Service user does not have permission to view ${getTicketLinkMarkup(request.ticket)}.`; break;
case 400: msg = `The bot had an issue and could not look up ${getTicketLinkMarkup(request.ticket)}:\n${formatApiError(response.error)}`; break;
case 400: msg = `JIRA returned a 500... not sure what to do with that.`; break;
default: msg = `Got ${response.status_code} and the bot cannot handle that.`; break;
}
return { message: { text: msg } };
}
if (request.type === 'issue') {
const issue = response.content;
issue.fields.priority = issue.fields.priority || {name: 'Unknown'};
const assignedTo = (issue.fields.assignee && issue.fields.assignee.displayName) ? issue.fields.assignee.displayName : 'unassigned';
const status = issue.fields.status && `${issue.fields.status.name}${issue.fields.status.statusCategory ? ` (${issue.fields.status.statusCategory.name})` : ''}`;
const message = {
icon_url: (issue.fields.project && issue.fields.project.avatarUrls && issue.fields.project.avatarUrls['48x48']) || undefined,
attachments: []
};
message.attachments.push({
author_name: `${issue.key}${issue.fields.issuetype ? ` (${issue.fields.issuetype.name})` : ''}`,
author_link: `${browseTicketBase}/${issue.key}`,
author_icon: issue.fields.issuetype && issue.fields.issuetype.iconUrl && issue.fields.issuetype.iconUrl.replace(/\.svg$/, '.png'),
title: issue.fields.summary,
thumb_url: issue.fields.priority.iconUrl && issue.fields.priority.iconUrl.replace(/\.svg$/, '.png') || undefined,
text: issue.fields.description || '_no description_',
fields: [
{
title: 'Priority',
value: issue.fields.priority.name,
short: true
},
{
title: 'Assigned to',
value: assignedTo,
short: true
},
{
title: 'Status',
value: status,
short: true
},
{
title: 'Comments',
value: issue.fields.comment.total,
short: true
},
]
});
return {content: message};
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment