YouTrack supports REST client implementation to the workflow API. You can use workflows to script push-style integrations with your favorite tools.
// Post issue content to a third-party tool and add the returned response as a comment
const connection = new http.Connection('https://server.com');
connection.addHeader('Content-Type', 'text/html');
const response = connection.postSync('/issueRegistry', [], issue.description);
if (response && response.code === 200) {
issue.addComment(response.response);
}
Authentication
The REST client supports the HTTP basic access authentication scheme via headers. To utilize this scheme, compute a base64(login:password) value and set the authorization header as follows:
connection.addHeader('Authorization', 'Basic amsudmNAbWFFR5ydTp5b3V0cmFjaw==');
Set the authorization header for every request, unless the target server provides cookies upon successful authentication.
HTTP cookies are managed transparently under the hood, when present. That is, if any REST call returns cookies, they persist automatically and provide access to the same domain until they expire. You can also set cookies manually in the header:
connection.addHeader('Cookie', 'MyServiceCookie=732423sdfs73242');
Case Studies
The following case studies illustrate how you can use the workflow REST API to integrate YouTrack with an external application.
Pastebin Integration
Pastebin is a website where you can store text online for a set period of time. You can paste any string of text like code snippets and extracts from log files.
In this case study, we extract code snippets from new issues and store them on Pastebin instead. The issue description retains a link to the content that is moved to Pastebin. The following workflow rule demonstrates how this scenario is implemented:
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const http = require('@jetbrains/youtrack-scripting-api/http');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
title: 'Export to Pastebin.com',
action: function (ctx) {
const issue = ctx.issue;
if (issue.becomesReported || (issue.isReported && issue.isChanged('description'))) {
// Find a code sample in issue description: the text between code markup tokens.
const findCode = function () {
const start = issue.description.indexOf('{code}');
if (start !== -1) {
const end = issue.description.indexOf('{code}', start + 1);
if (end !== -1) {
return issue.description.substring(start + 6, end);
}
}
return '';
};
const code = findCode();
if (code.length !== 0) {
const connection = new http.Connection('https://pastebin.com');
connection.addHeader('Content-Type', 'application/x-www-form-urlencoded');
// Pastebin accepts only forms, so we pack everything as form fields.
// Authentication of performed via api developer key.
const payload = [];
payload.push({name: 'api_option', value: 'paste'});
payload.push({name: 'api_dev_key', value: '98bcac75e1e327b54c08947ea1dbcb7e'});
payload.push({name: 'api_paste_private', value: 1});
payload.push({name: 'api_paste_name', value: 'Code sample from issue ' + issue.id});
payload.push({name: 'api_paste_code', value: code.trim()});
const response = connection.postSync('/api/api_post.php', [], payload);
if (response.code === 200 && response.response.indexOf('https://pastebin.com/') !== -1) {
const url = response.response;
issue.description = issue.description.replace('{code}' + code + '{code}',
'See sample at ' + url);
workflow.message('Code sample is moved at <a href="' + url + '">' + url + "</a>");
} else {
workflow.message('Failed to replace code due to: ' + response.response);
}
}
}
}
});
On the other hand, we may want to do the opposite: to expand any Pastebin
link we met into a code snippet, that is, to download it and insert into issue. Let's try to code it:
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const http = require('@jetbrains/youtrack-scripting-api/http');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
title: 'Import from Pastebin.com',
action: function (ctx) {
const issue = ctx.issue;
if (issue.becomesReported || (issue.isReported && issue.isChanged('description'))) {
const baseUrl = "https://pastebin.com/";
const urlBaseLength = baseUrl.length;
// Check, if issue description contains a link to pastebin.
const linkStart = issue.description.indexOf(baseUrl);
if (linkStart !== -1) {
// So we found a link, let's extract the key and download the contents via API.
const pasteKey = issue.description.substring(linkStart + urlBaseLength, linkStart + urlBaseLength + 8);
const connection = new http.Connection('https://pastebin.com');
const response = connection.getSync('/raw/' + pasteKey, []);
if (response.code === 200) {
const url = baseUrl + pasteKey;
issue.description = issue.description.replace(url, '{code}' + response.response + '{code}');
workflow.message('Code sample is moved from <a href="' + url + '">' + url + "</a>");
} else {
workflow.message('Failed to import code due to: ' + response.response);
}
}
}
}
});
Custom Time Tracking with the Harvest Web Service
Suppose that we want to bill customers for the working hours that we record in YouTrack. The problem is that YouTrack isn't really built for managing invoices and associating spent time with specific customers. An integration with a dedicated time tracking service can make life a lot easier.
Let's first introduce a common part for all scripts below: a common custom script, containing connection initialization and common payload fields:
const http = require('@jetbrains/youtrack-scripting-api/http');
exports.userIds = {
'jane.smith': '1790518',
'john.black': '1703589'
};
exports.initConnection = function () {
const connection = new http.Connection('https://yourapp.harvestapp.com');
// see https://help.getharvest.com/api-v1/authentication/authentication/http-basic/
connection.addHeader('Authorization',
'Basic bXJzLm1hcml5YS8kYXZ5ZG94YUBnbWFpbC0jb206a3V6eWEyMDA0');
connection.addHeader('Accept', 'application/json');
connection.addHeader('Content-Type', 'application/json');
return connection;
};
exports.initPayload = function (user) {
return {
project_id: '14383202',
task_id: '8120350',
user_id: exports.userIds[user.login]
};
};
One possible scenario is to introduce a custom field - Billable hours - and post changes to the value of this field to the Harvest web service.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
const common = require('./common');
exports.rule = entities.Issue.onChange({
title: 'Post Work Item',
action: function (ctx) {
const issue = ctx.issue;
if (issue.fields.isChanged(ctx.Hours)) {
const hours = (issue.fields.Hours || 0) - (issue.fields.oldValue(ctx.Hours) || 0);
const connection = common.initConnection();
const payload = common.initPayload(ctx.currentUser);
payload.hours = hours;
const response = connection.postSync('/daily/add', [], payload);
if (response && response.code === 201) {
workflow.message('A work item was added to Harvest!');
} else {
workflow.message('Something went wrong when adding a work item to Harvest: ' + response);
}
}
},
requirements: {
Hours: {
type: entities.Field.integerType,
name: 'Billable hours'
}
}
});
Let's consider another option: start time tracking when an issue moves to an In Progress state and stop time tracking when the issue is resolved. Luckily for us, Harvest has a timer API that we can use to start and stop the timers remotely. The Harvest ID custom field is required to store the timer identifier.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
const common = require('./common');
exports.rule = entities.Issue.onChange({
title: 'Start Timer',
action: function (ctx) {
const issue = ctx.issue;
if (issue.fields.becomes(ctx.State, ctx.State['In Progress'])) {
const connection = common.initConnection();
const payload = common.initPayload(ctx.currentUser);
const response = connection.postSync('/daily/add', [], payload);
if (response && response.code === 201) {
issue.fields.HID = JSON.parse(response.response).id;
workflow.message('A timer is started at Harvest!');
} else {
workflow.message('Something went wrong when starting a timer at Harvest: ' + response);
}
}
},
requirements: {
HID: {
type: entities.Field.stringType,
name: 'Harvest ID'
},
State: {
type: entities.State.fieldType,
'In Progress': {}
}
}
});
The following workflow rule stops the Harvest timer when an issue is resolved.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
const common = require('./common');
exports.rule = entities.Issue.onChange({
title: 'Stop Timer',
action: function (ctx) {
const issue = ctx.issue;
if (issue.becomesResolved && issue.fields.HID) {
const connection = common.initConnection();
const response = connection.getSync('/daily/timer/' + issue.fields.HID);
if (response && response.code === 200) {
workflow.message('A timer is stopped at Harvest!');
} else {
workflow.message('Something went wrong when stopping a timer at Harvest: ' + response);
}
}
},
requirements: {
HID: {
type: entities.Field.stringType,
name: 'Harvest ID'
}
}
});
Posting Binary Content with multipart/form-data Type
When you need to post files from YouTrack to a third-party application, the target application might require you to make POST requests with the Content-Type
header value set to multipart/form-data
.
To make such a request from a YouTrack workflow rule, pass an object for the payload
parameter of the Connection.postSync
method and set its type
value to 'multipart/form-data'
.
In the parts
parameter of the payload, YouTrack expects to find an array consisting of the attachment parts. You can send as many parts as necessary.
For each element of the parts
array, there are the following fields available:
Field | Type | Description | Required |
---|
name | String | The name of the part. | ✓ |
size | Number | The size of the attached file in bytes. | ✓ |
fileName | String | The name of the attachment file. | ✓ |
content | InputStream | String | The content of the file. When the contentType is not set explicitly, YouTrack expects the content as an InputStream in binary form. | ✓ |
contentType | String | The content type of the file. For each individual part, you can set the contentType value separately. Depending on the contentType value, YouTrack expects different types of the content . For example, if you set contentType: 'application/json' , the content value must be in JSON format. | |
Here is an example of a workflow rule that makes a POST request and passes an attachment with the multipart/form-data
type.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const http = require('@jetbrains/youtrack-scripting-api/http');
exports.rule = entities.Issue.action({
title: 'Reattach the first attachment',
// The base URL is taken from the first line of the issue description.
// Auth details are taken from the second of the issue description, the user ID - from the third line.
command: 'reattach',
guard: (ctx) => {
return true;
},
action: (ctx) => {
const issue = ctx.issue;
const baseURL = issue.description.split('\n')[0].trim()
const auth = issue.description.split('\n')[1].trim()
const rootUserId = issue.description.split('\n')[2].trim()
const connection = new http.Connection(baseURL);
const attachment = issue.attachments.first();
connection.addHeader('authorization', auth);
connection.postSync('issues/' + issue.id + '/attachments', [], {
type: 'multipart/form-data',
parts: [
{
name: 'my-part-name',
size: attachment.size,
fileName: 'filename',
content: attachment.content
}
]
});
},
requirements: {}
});