Skip to content

Instantly share code, notes, and snippets.

@zdenekdrahos
Last active October 16, 2020 17:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zdenekdrahos/8ba762bfac5f6bc9dd6b3c59df21476d to your computer and use it in GitHub Desktop.
Save zdenekdrahos/8ba762bfac5f6bc9dd6b3c59df21476d to your computer and use it in GitHub Desktop.
Toggl time-entries -> JIRA (tempo) worklogs
config.php
*.json
*.log

Toggl -> JIRA

1) Install

git clone https://gist.github.com/8ba762bfac5f6bc9dd6b3c59df21476d.git toggl-jira
cd toggl-jira
# sync.php will tell what must be configured
php sync.php
    CONFIG IS MISSING:
    1. Create config.php in same directory as this file:
        $ cp config.example.php ./config.php
    2. Configure your apps (api tokens, project):
        $ nano ./config.php
    3. Run synchronization:
        $ php sync.php
        $ DAY_FROM="now - 7 days" DAY_TO=today php sync.php

1a) JIRA config

  1. Find accountId - use REST API (needs different api token), or just debug user endpoint in XHR network

    curl -u email:cloud_api_token \
        -X GET -H "Content-Type: application/json" \
        https://signme.atlassian.net/rest/api/3/myself

  2. Generate Tempo API token

    tempo token

  3. Try Tempo API

    curl -v -H "Authorization: Bearer $TEMPO_TOKEN" "https://api.tempo.io/core/3/work-attributes" > tempo-test.json

1b) Toggl config

  1. Go to toggl reports - https://www.toggl.com/app/reports
  2. Select your project - e.g. https://www.toggl.com/app/reports/summary/647550/period/thisMonth/projects/157590463
  3. Copy ids to config.php - first number is workspace, second number is project
  4. Copy apitoken from profile - https://www.toggl.com/app/profile

2) Sync

Default query is yesterday:

$ php sync.php
    Add YES, if you'd like to update JIRA:
    $ DAY_FROM=yesterday DAY_TO=yesterday php sync.php YES

You can define custom date range with php relative dates (wrike, easyproject)

# last 7 days
$ DAY_FROM="now - 7 days" DAY_TO=today php sync.php

# current month
$ DAY_FROM="first day of this month" DAY_TO="last day of this month" php sync.php

3) Crontab sync

# sync previous day
1 2 * * * DAY_FROM=yesterday DAY_TO=yesterday /path-to-gist-dir/cron
# ressync current week on Sunday
9 2 * * 0 DAY_FROM="now - 7 days" DAY_TO=yesterday /path-to-gist-dir/cron

Links

<?php
return [
'toggl' => [
'basicAuth' => ['token from https://www.toggl.com/app/profile', 'api_token'],
'workspace' => 'workspace id (first number) from https://www.toggl.com/app/reports',
'project' => 'project id (second number) from https://www.toggl.com/app/reports',
],
'jira' => [
'timeentryToWorklog' => buildJiraMappingFromDescription([
'accountId' => 'accountId from https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-myself-get',
'isDescriptionShortened' => false,
]),
'tempoBearerToken' => 'token from tempo > settings > api',
],
'dbFile' => __DIR__ . '/db.json', // I recommended file that is automatically backed up, e.g. ~/Dropbox/db.json
];
#!/bin/bash
#
# Dry-run
# IS_JIRA_UPDATED="no" DAY_FROM="today" DAY_TO=today /path-to-gist-dir/cron
#
# Add cron
# Daily:
# DAY_FROM="today" DAY_TO=today /path-to-gist-dir/cron
# Last week:
# DAY_FROM="now - 7 days" DAY_TO=yesterday /path-to-gist-dir/cron
# Custom date range
# DAY_FROM="2020-04-01" DAY_TO="2020-04-01" /path-to-gist-dir/cron
set -e
set -u
IS_JIRA_UPDATED=${IS_JIRA_UPDATED:-"YES"}
DAY_FROM=${DAY_FROM:-"yesterday"}
DAY_TO=${DAY_TO:-"yesterday"}
CURRENT_DIR="$(realpath $(dirname $(realpath $0)))"
LOG="$CURRENT_DIR/api-log/$(date +"%Y-%m-%d-%H%M%S").log"
run () {
echo "Sync <$DAY_FROM, $DAY_TO> in $CURRENT_DIR"
(
cd $CURRENT_DIR
DAY_FROM="$DAY_FROM" DAY_TO="$DAY_TO" php sync.php "$IS_JIRA_UPDATED"
)
}
run >> $LOG 2>&1
echo $LOG
<?php
$config = getConfig(__DIR__ . '/config.php');
sync($config, getenv(), $argv);
function getConfig(string $file)
{
if (file_exists($file)) {
return require_once($file);
}
echo <<<TXT
CONFIG IS MISSING:
1. Create config.php in same directory as this file:
$ cp config.example.php $file
2. Configure your apps (api tokens, project):
$ nano $file
3. Run synchronization:
$ php sync.php
$ DAY_FROM="now - 7 days" DAY_TO=today php sync.php
TXT;
exit(1);
}
function sync(array $config, array $queryEnv, array $cliArgs)
{
$query = [
'dayFrom' => $queryEnv['DAY_FROM'] ?? 'yesterday',
'dayTo' => $queryEnv['DAY_TO'] ?? 'yesterday',
];
list($findInDb, $addToDb) = initDb($config['dbFile']);
$togglTimeentries = getTogglTimeentries($config['toggl'], $query);
$jiraWorklogs = togglTimeentriesToJiraTempo($togglTimeentries, $config['jira']['timeentryToWorklog'], $findInDb);
$const = 'strval';
echo <<<TXT
QUERY: {$const(json_encode($query))}
TOGGL TIMEENTRIES: {$const(count($togglTimeentries))}
JIRA WORKLOGS:
- create: {$const(count($jiraWorklogs['create']))}
- update: {$const(count($jiraWorklogs['update']))}
- unmapped: {$const(count($jiraWorklogs['unmapped']))}
------------------
TXT;
list($command, $isJiraUpdated) = $cliArgs + [0 => __FILE__, 1 => 'NO'];
if ($isJiraUpdated != 'YES') {
saveApiLog($jiraWorklogs);
echo <<<TXT
Add YES, if you'd like to update JIRA:
$ DAY_FROM={$query['dayFrom']} DAY_TO={$query['dayTo']} php {$command} YES
TXT;
return;
}
upsertJiraWorklogs($jiraWorklogs, $config['jira']['tempoBearerToken'], $addToDb);
echo "JIRA updated!\n";
}
function getTogglTimeentries(array $togglConfig, array $query): array
{
$data = [];
$page = 1;
$hasNextPage = true;
while ($hasNextPage) {
$response = getTogglTimeentriesPage($togglConfig, $query, $page);
$data = array_merge($data, $response['data']);
$hasNextPage = $response['total_count'] > count($data);
$page++;
}
return $data;
}
function getTogglTimeentriesPage(array $togglConfig, array $query, int $page): array
{
return httpRequest([
'method' => 'GET',
'url' => 'https://api.track.toggl.com/reports/api/v2/details',
'query' => [
'workspace_id' => $togglConfig['workspace'],
'project_ids' => $togglConfig['project'],
'user_agent' => 'api_timesheet',
'since' => date('Y-m-d', strtotime($query['dayFrom'])),
'until' => date('Y-m-d', strtotime($query['dayTo'])),
'page' => $page,
],
'basicAuth' => $togglConfig['basicAuth'],
]);
}
function togglTimeentriesToJiraTempo(array $togglTimeentries, callable $buildMapping, callable $findInDb): array
{
$worklogs = [
'create' => [],
'update' => [],
'unmapped' => [],
];
foreach ($togglTimeentries as $timeentry) {
$mapping = $buildMapping($timeentry);
if (!array_key_exists('issueKey', $mapping)) {
$worklogs['unmapped'][] = $timeentry;
continue;
}
$datetime = \DateTime::createFromFormat('Y-m-d\TH:i:sP', $timeentry['start']);
$worklog = [
'startDate' => $datetime->format('Y-m-d'),
'startTime' => $datetime->format('H:i:s'),
"timeSpentSeconds" => (int) ($timeentry['dur'] / 1000),
// can't use attrbutes (A work attribute with key '_EXTERNALREF_' does not exist)
'togglTimeentryId' => $timeentry['id'],
] + $mapping;
$worklogId = $findInDb($timeentry['id']);
if ($worklogId) {
$worklogs['update'][$worklogId] = $worklog;
} else {
$worklogs['create'][] = $worklog;
}
}
return $worklogs;
}
/**
* Given toggl description `#123 category - long description`
* - JIRA issue is IS-123
* - JIRA description depends on $isDescriptionShortened
* - true: `#123 category`
* - false: `#123 category - long description`
*/
function buildJiraMappingFromDescription(array $config): callable
{
$config += [
'projectKey' => 'IS',
'accountId' => null,
'isDescriptionShortened' => false,
];
return function (array $timeentry) use ($config) {
$category = trim(explode(' - ', $timeentry['description'])[0]);
$keys = explode(' ', str_replace('#', '', $category), 2) + [null, null];
if (!is_numeric($keys[0])) {
return [];
}
return [
'issueKey' => "{$config['projectKey']}-{$keys[0]}",
'authorAccountId' => $config['accountId'],
'description' => $config['isDescriptionShortened'] ? $category : $timeentry['description'],
];
};
}
function upsertJiraWorklogs(array $worklogs, string $tempoBearerToken, callable $addToDb)
{
$upsert = function (array $worklog, $worklogId = null) use ($tempoBearerToken, $addToDb) {
$togglTimeentryId = $worklog['togglTimeentryId'];
unset($worklog['togglTimeentryId']);
$response = httpRequest([
'method' => $worklogId ? 'PUT' : 'POST',
'url' => 'https://api.tempo.io/core/3/worklogs' . ($worklogId ? "/{$worklogId}" : ''),
'json' => $worklog,
'headers' => [
"Authorization: Bearer $tempoBearerToken"
],
]);
$newWorklogId = $response['tempoWorklogId'] ?? null;
if (!$newWorklogId || ($worklogId && $worklogId != $newWorklogId)) {
$worklogJson = json_encode($worklog);
echo <<<TXT
Unexpected workload response: response: [{$newWorklogId}], updated worklog: [{$worklogId}]
- toggl time-entry id: {$togglTimeentryId}
- JIRA worklog: {$worklogJson}
TXT;
return;
}
$addToDb($togglTimeentryId, $newWorklogId);
};
foreach ($worklogs['create'] as $worklog) {
$upsert($worklog);
}
foreach ($worklogs['update'] as $worklogId => $worklog) {
$upsert($worklog, $worklogId);
}
}
function httpRequest(array $rawRequest): array
{
$request = $rawRequest + ['method' => 'GET', 'query' => [], 'json' => [], 'headers' => [], 'basicAuth' => []];
$curl = curl_init();
$options = [
CURLOPT_CUSTOMREQUEST => $request['method'],
CURLOPT_URL => $request['url'] . ($request['query'] ? ('?' . http_build_query($request['query'])) : ''),
CURLOPT_POSTFIELDS => $request['json'] ? json_encode($request['json']) : null,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $request['headers']),
CURLOPT_RETURNTRANSFER => true,
];
if ($request['basicAuth']) {
$options += [
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => implode(':', $request['basicAuth']),
];
}
curl_setopt_array($curl, $options);
$response = curl_exec($curl);
curl_close($curl);
saveApiLog($response);
return json_decode($response, true);
}
function initDb(string $file): array
{
$db = loadJsonFile($file);
$findInDb = function ($timeentryId) use ($db) {
return $db[$timeentryId] ?? null;
};
$addToDb = function ($timeentryId, $worklogId) use (&$db, $file) {
$db[$timeentryId] = $worklogId;
saveJsonFile($file, $db);
};
return [$findInDb, $addToDb];
}
function loadJsonFile(string $file): array
{
return file_exists($file) ? json_decode(file_get_contents($file), true) : [];
}
function saveApiLog($content)
{
static $count = 0;
$count++;
$json = is_array($content) ? $content : json_decode($content);
$apiLogsDir = __DIR__ . '/api-log';
if (!is_dir($apiLogsDir)) {
mkdir($apiLogsDir);
}
if ($json) {
saveJsonFile("{$apiLogsDir}/{$count}.json", $json);
} else {
saveFile("{$apiLogsDir}/{$count}.log", $content);
}
}
function saveJsonFile(string $file, $content)
{
saveFile($file, json_encode($content, JSON_PRETTY_PRINT));
}
function saveFile(string $file, $content)
{
file_put_contents($file, $content);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment