|
<?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); |
|
} |