diff --git a/composer.json b/composer.json index 0b1e408..76623bc 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "php": ">=7.2", "ext-sqlite3": "*", "ext-pdo_sqlite": "*", - "lesstif/php-jira-rest-client": "^1.35" + "lesstif/php-jira-rest-client": "^1.35", + "morningtrain/toggl-api": "^1.0" }, "require-dev": { "phpunit/phpunit": "~6", diff --git a/composer.lock b/composer.lock index 280f539..33383f8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "816c13378857ed822b07c7c66b0219fb", + "content-hash": "674ae05c182538ff13c42977a20423a3", "packages": [ { "name": "doctrine/annotations", @@ -1031,6 +1031,54 @@ ], "time": "2017-06-19T01:22:40+00:00" }, + { + "name": "morningtrain/toggl-api", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/Morning-Train/toggl-api.git", + "reference": "231f9bdb7637652e447829bdee694f205b4b106c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Morning-Train/toggl-api/zipball/231f9bdb7637652e447829bdee694f205b4b106c", + "reference": "231f9bdb7637652e447829bdee694f205b4b106c", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3" + }, + "require-dev": { + "escapestudios/symfony2-coding-standard": "2.*", + "squizlabs/php_codesniffer": "2.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "MorningTrain\\TogglApi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GNU General Public License v3.0" + ], + "authors": [ + { + "name": "Bjarne Bonde", + "email": "bb@morningtain.dk", + "homepage": "http://morningtrain.dk/", + "role": "Developer" + } + ], + "description": "A complete native php wrapper for the Toggl API", + "homepage": "http://morningtrain.dk/", + "keywords": [ + "api", + "php", + "toggl" + ], + "time": "2019-03-04T07:37:07+00:00" + }, { "name": "netresearch/jsonmapper", "version": "v1.4.0", @@ -1769,7 +1817,7 @@ "version": "8.3.1", "source": { "type": "git", - "url": "https://git.drupal.org/project/coder.git", + "url": "https://git.drupalcode.org/project/coder.git", "reference": "29a25627e7148b3119c84f18e087fc3b8c85b959" }, "require": { diff --git a/phpcs.xml b/phpcs.xml index a14c934..b537108 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -17,6 +17,10 @@ Repository.php + + Chunk.php + DbRepository.php + Schema.php diff --git a/services.yml b/services.yml index ac000a5..8eb577d 100644 --- a/services.yml +++ b/services.yml @@ -20,16 +20,26 @@ services: http_client: class: GuzzleHttp\Client cache: - class: Doctrine\Common\Cache\FileSystemCache + class: Doctrine\Common\Cache\FilesystemCache factory: [Larowlan\Tl\CacheFactory, create] repository: class: Larowlan\Tl\Repository\DbRepository arguments: ["@connection"] connector: class: Larowlan\Tl\Connector\Manager - arguments: ['@container', '@cache', '%config%', '%version%'] + arguments: ['@container', '@cache', '%config%', '%version%', '@reporter'] tags: - { name: configurable } + reporter: + class: Larowlan\Tl\Reporter\Manager + arguments: ['@container', '@cache', '%config%', '%version%'] + tags: + - { name: configurable } + reporter.toggl: + class: Larowlan\Tl\Reporter\Toggl + arguments: ['%config%', '@cache', '%version%'] + tags: + - { name: reporter } connector.redmine: class: Larowlan\Tl\Connector\RedmineConnector arguments: ['@http_client', '@cache', '%config%', '%version%'] diff --git a/src/Chunk.php b/src/Chunk.php new file mode 100644 index 0000000..f4efdb4 --- /dev/null +++ b/src/Chunk.php @@ -0,0 +1,100 @@ +id = $id; + $this->start = $start; + $this->end = $end; + } + + /** + * Gets value of Start. + * + * @return int + * Value of Start. + */ + public function getStart(): int { + return $this->start; + } + + /** + * Gets value of End. + * + * @return int + * Value of End. + */ + public function getEnd(): ?int { + return $this->end; + } + + /** + * Gets value of Id. + * + * @return int + * Value of Id. + */ + public function getId(): int { + return $this->id; + } + + /** + * Sets value of End. + * + * @param int $end + * Value for End. + * + * @return $this + */ + public function setEnd(int $end): Chunk { + $this->end = $end; + return $this; + } + + /** + * Gets duration. + * + * @return int + * Duration. + */ + public function getDuration() : int { + return ($this->end ?: time()) - $this->start; + } + +} diff --git a/src/Commands/Bitbar.php b/src/Commands/Bitbar.php index acc8643..8fa40c3 100644 --- a/src/Commands/Bitbar.php +++ b/src/Commands/Bitbar.php @@ -49,16 +49,17 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output) { if ($open = $this->repository->getActive()) { - $text = $open->tid . ': ' . Formatter::formatDuration(time() - $open->start) . ' '; + $text = $open->getTicketId() . ': ' . Formatter::formatDuration($open->getDuration()) . ' '; } else { $text = 'Inactive '; } $total = 0; + /** @var \Larowlan\Tl\Slot $data */ foreach ($this->repository->review(Total::ALL) as $data) { - $total += $data->duration; + $total += $data->getDuration(FALSE, TRUE); } - $text .= '(' . $total . 'h)'; + $text .= '(' . $total / 3600 . 'h)'; $output->writeln($text); } diff --git a/src/Commands/Combine.php b/src/Commands/Combine.php index d2021ba..84dd7a9 100644 --- a/src/Commands/Combine.php +++ b/src/Commands/Combine.php @@ -54,24 +54,13 @@ protected function configure() { protected function execute(InputInterface $input, OutputInterface $output) { $slot1 = $input->getArgument('slot1'); $slot2 = $input->getArgument('slot2'); - list($entry1, $entry2) = $this->validateSlots($slot1, $slot2, $output); - - // Create a new combined entry and then remove fields we don't want to keep - // around in the new entry. - $combined_entry = clone $entry1; - unset($combined_entry->id, $combined_entry->category, $combined_entry->comment, $combined_entry->teid, $combined_entry->connector_id); - $combined_entry->connector_id = ':connector_id'; - - // Extend the entry date by the amount of time logged in the second entry. - $combined_entry->end = $entry1->end + ($entry2->end - $entry2->start); - // Insert the new entry, if all is well delete the two existing ones. - if ($new_slot = $this->repository->insert((array) $combined_entry, [':connector_id' => $entry1->connector_id])) { - $this->repository->delete($entry1->id); - $this->repository->delete($entry2->id); + /** @var \Larowlan\Tl\Slot $entry1 */ + /** @var \Larowlan\Tl\Slot $entry2 */ + list($entry1, $entry2) = $this->validateSlots($slot1, $slot2, $output); + $this->repository->combine($entry1, $entry2); - $output->writeln(sprintf('Combined %s and %s into new slot %s', $slot1, $slot2, $new_slot)); - } + $output->writeln(sprintf('Combined %s and %s into new slot %s', $slot1, $slot2, $slot1)); } /** @@ -92,15 +81,15 @@ protected function validateSlots($slot1, $slot2, OutputInterface $output) { if (!$entry2 = $this->repository->slot($slot2)) { throw new \InvalidArgumentException(sprintf('Invalid slot id %s', $slot2)); } - if ($entry1->connector_id !== $entry2->connector_id) { - throw new \InvalidArgumentException(sprintf('You cannot combine slots from %s backend with slots from %s backend', Manager::formatConnectorId($entry2->connector_id), Manager::formatConnectorId($entry1->connector_id))); + if ($entry1->getConnectorId() !== $entry2->getConnectorId()) { + throw new \InvalidArgumentException(sprintf('You cannot combine slots from %s backend with slots from %s backend', Manager::formatConnectorId($entry2->getConnectorId()), Manager::formatConnectorId($entry1->getConnectorId()))); } // Ensure we've not already sent the slots. - if (!empty($entry1->teid) || !empty($entry1->teid)) { + if (!empty($entry1->getRemoteEntryId()) || !empty($entry1->getRemoteEntryId())) { throw new \InvalidArgumentException('You cannot combine entries that have already been sent.'); } // Ensure the slots are both against the same job. - if ($entry1->tid != $entry2->tid) { + if ($entry1->getTicketId() != $entry2->getTicketId()) { throw new \InvalidArgumentException('You cannot combine entries from separate issues.'); } return [$entry1, $entry2]; @@ -111,12 +100,12 @@ protected function validateSlots($slot1, $slot2, OutputInterface $output) { */ protected function stopTicket($slot_id, OutputInterface $output) { if ($stop = $this->repository->stop($slot_id)) { - $stopped = $this->connector->ticketDetails($stop->tid, $stop->connector_id); + $stopped = $this->connector->ticketDetails($stop->getTicketId(), $stop->getConnectorId()); $output->writeln(sprintf('Closed slot %d against ticket %d: %s, duration %s', - $stop->id, - $stop->tid, + $stop->getId(), + $stop->getTicketId(), $stopped->getTitle(), - Formatter::formatDuration($stop->duration) + Formatter::formatDuration($stop->getDuration()) )); } } diff --git a/src/Commands/Comment.php b/src/Commands/Comment.php index ec9d57a..176791a 100644 --- a/src/Commands/Comment.php +++ b/src/Commands/Comment.php @@ -56,23 +56,24 @@ protected function execute(InputInterface $input, OutputInterface $output) { $entries = $this->repository->review(Review::ALL, !$input->getOption('recomment')); $helper = $this->getHelper('question'); $last = FALSE; + /** @var \Larowlan\Tl\Slot $entry */ foreach ($entries as $entry) { - if ($entry->comment && !$input->getOption('recomment')) { + if ($entry->getComment() && !$input->getOption('recomment')) { continue; } - $title = $this->connector->ticketDetails($entry->tid, $entry->connector_id); + $title = $this->connector->ticketDetails($entry->getTicketId(), $entry->getConnectorId()); $question = new Question( sprintf('Enter comment for slot %d [%d]: %s [%s h] [%s]', - $entry->id, - $entry->tid, + $entry->getId(), + $entry->getTicketId(), $title->getTitle(), - $entry->duration, + $entry->getDuration(FALSE, TRUE) / 3600, $last ?: static::DEFAULT_COMMENT ), $last ?: static::DEFAULT_COMMENT ); $comment = $helper->ask($input, $output, $question); - $this->repository->comment($entry->id, $comment); + $this->repository->comment($entry->getId(), $comment); $last = $comment; } if (!$last) { diff --git a/src/Commands/Continues.php b/src/Commands/Continues.php index 9f95152..6171f2a 100644 --- a/src/Commands/Continues.php +++ b/src/Commands/Continues.php @@ -51,20 +51,21 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output) { if ($slot_id = $input->getArgument('slot_id')) { + $this->repository->stop(); $slot = $this->repository->slot($slot_id); } else { - $slot = $this->repository->latest(); + $slot = $this->repository->stop() ?: $this->repository->latest(); } if ($slot) { - $details = $this->connector->ticketDetails($slot->tid, $slot->connector_id); - list($slot_id, $continued) = $this->repository->start($slot->tid, $slot->connector_id, $slot->comment, $slot->id); + $details = $this->connector->ticketDetails($slot->getTicketId(), $slot->getConnectorId()); + $start = $this->repository->start($slot->getTicketId(), $slot->getConnectorId(), $slot->getComment(), $slot->getId()); $output->writeln(sprintf('[%s] %s entry for %d: %s [slot:%d]', (new \DateTime())->format('h:i'), - $continued ? 'Continued' : 'Started new', - $slot->tid, + $start->isContinued() ? 'Continued' : 'Started new', + $slot->getTicketId(), $details->getTitle(), - $slot_id + $slot->getId() )); return; diff --git a/src/Commands/Delete.php b/src/Commands/Delete.php index 2f3cbb7..f7e1bc2 100644 --- a/src/Commands/Delete.php +++ b/src/Commands/Delete.php @@ -60,12 +60,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $confirm = NULL; if (($slot = $this->repository->slot($slot_id)) && ($confirm = ($input->getOption('confirm') || $helper->ask($input, $output, $question))) && $this->repository->delete($slot_id)) { - $deleted = $this->connector->ticketDetails($slot->tid, $slot->connector_id); + $deleted = $this->connector->ticketDetails($slot->getTicketId(), $slot->getConnectorId()); $output->writeln(sprintf('Deleted slot %d against ticket %d: %s, duration %s', - $slot->id, - $slot->tid, + $slot->getId(), + $slot->getTicketId(), $deleted->getTitle(), - Formatter::formatDuration($slot->end - $slot->start) + Formatter::formatDuration($slot->getDuration()) )); return; } diff --git a/src/Commands/Edit.php b/src/Commands/Edit.php index 4c259ff..3ee05d9 100644 --- a/src/Commands/Edit.php +++ b/src/Commands/Edit.php @@ -63,13 +63,13 @@ protected function execute(InputInterface $input, OutputInterface $output) { } $slot = $this->repository->slot($slot_id); - if ($slot === FALSE) { + if ($slot === NULL) { $output->writeln('Slot does not exist.'); return 1; } - if (isset($slot->teid)) { + if ($slot->getRemoteEntryId()) { $output->writeln('You cannot edit a slot that has been sent to the backend'); return 1; } diff --git a/src/Commands/Install.php b/src/Commands/Install.php index e546205..7a0e323 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -8,6 +8,7 @@ use Larowlan\Tl\Repository\Schema; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -35,6 +36,8 @@ public function __construct(Connection $connection, $directory, Schema $schema) protected function configure() { $this ->setName('install') + ->addOption('debug', 'd', InputOption::VALUE_NONE, 'Output the commands to be run') + ->addOption('skip-post', 's', InputOption::VALUE_NONE, 'Skip post install commands') ->setDescription('Installs the sqlite schema'); } @@ -48,9 +51,23 @@ protected function execute(InputInterface $input, OutputInterface $output) { $comparator = new Comparator(); $difference = $comparator->compare($existing, $schema); $count = 0; + $debug = $input->getOption('debug'); + $skip = $input->getOption('skip-post'); if ($statements = $difference->toSql($sm->getDatabasePlatform())) { foreach ($statements as $statement) { + $hash = hash('sha256', $statement); + $post = $this->postInstall($hash); + if ($debug) { + $output->writeln($statement); + if ($post) { + $output->writeln($post); + } + continue; + } $this->connection->exec($statement); + if (!$skip && $post) { + $this->connection->exec($post); + } $count++; } $output->writeln(sprintf('Executed %d queries', $count)); @@ -60,4 +77,19 @@ protected function execute(InputInterface $input, OutputInterface $output) { } } + /** + * Migration tasks. + * + * @param string $hash + * Statement hash + * + * @return string + */ + protected function postInstall(string $hash) :?string { + if ($hash === '8f5382517658027fcea333e593055c1133fe6f1576b3211391481ba9a8a51e57') { + return "INSERT INTO chunks (sid, start, end) SELECT id, start, end FROM slots"; + } + return NULL; + } + } diff --git a/src/Commands/MostFrequentlyUsed.php b/src/Commands/MostFrequentlyUsed.php index dd1390c..78b75e3 100644 --- a/src/Commands/MostFrequentlyUsed.php +++ b/src/Commands/MostFrequentlyUsed.php @@ -53,11 +53,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $table->setHeaders(['JobId', 'Title']); $rows = []; + /** @var \Larowlan\Tl\Slot $entry */ foreach ($entries as $entry) { - if (!$details = $this->connector->ticketDetails($entry->tid, $entry->connector_id)) { + if (!$details = $this->connector->ticketDetails($entry->getTicketId(), $entry->getConnectorId())) { continue; } - $rows[] = [$entry->tid, $details->getTitle()]; + $rows[] = [$entry->getTicketId(), $details->getTitle()]; } $table->setRows($rows); $table->render(); diff --git a/src/Commands/Open.php b/src/Commands/Open.php index e6f6144..3bbfb29 100644 --- a/src/Commands/Open.php +++ b/src/Commands/Open.php @@ -48,12 +48,12 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output) { if ($data = $this->repository->getActive()) { - $details = $this->connector->ticketDetails($data->tid, $data->connector_id); + $details = $this->connector->ticketDetails($data->getTicketId(), $data->getConnectorId()); $output->writeLn(sprintf('%s [%d] - %s [slot: %d]', $details->getTitle(), - $data->tid, - Formatter::formatDuration(time() - $data->start), - $data->id + $data->getTicketId(), + Formatter::formatDuration($data->getDuration()), + $data->getId() )); return; } diff --git a/src/Commands/Send.php b/src/Commands/Send.php index 1e46542..16b885f 100644 --- a/src/Commands/Send.php +++ b/src/Commands/Send.php @@ -101,19 +101,20 @@ protected function execute(InputInterface $input, OutputInterface $output) { $progress->setFormat('custom'); $progress->setProgressCharacter("\xF0\x9F\x8D\xBA"); $errors = FALSE; + /** @var \Larowlan\Tl\Slot $entry */ foreach ($entries as $entry) { try { - if ((float) $entry->duration == 0) { + if ((float) $entry->getDuration(FALSE, TRUE) == 0) { // Nothing to send, but mark sent so it doesn't show up tomorrow. - $this->repository->store([$entry->tid => 0]); - $this->progress($progress, sprintf('Marked entry for %d as sent, < 15 minutes', $entry->tid)); + $this->repository->store([$entry->getTicketId() => 0]); + $this->progress($progress, sprintf('Marked entry for %d as sent, < 15 minutes', $entry->getTicketId())); $progress->advance(); continue; } if ($saved = $this->connector->sendEntry($entry)) { - $entry_ids[$entry->tid] = $saved; + $entry_ids[$entry->getTicketId()] = $saved; // A real entry, give some output. - $this->progress($progress, sprintf('Stored entry for %d, remote id %d', $entry->tid, $entry_ids[$entry->tid])); + $this->progress($progress, sprintf('Stored entry for %d, remote id %d', $entry->getTicketId(), $entry_ids[$entry->getTicketId()])); $progress->advance(); } } diff --git a/src/Commands/Start.php b/src/Commands/Start.php index 67143b6..8335f51 100644 --- a/src/Commands/Start.php +++ b/src/Commands/Start.php @@ -87,16 +87,18 @@ protected function execute(InputInterface $input, OutputInterface $output) { } if ($title = $this->connector->ticketDetails($ticket_id, $connector_id)) { if ($stop = $this->repository->stop()) { - $stopped = $this->connector->ticketDetails($stop->tid, $stop->connector_id); + $stopped = $this->connector->ticketDetails($stop->getTicketId(), $stop->getConnectorId()); $output->writeln(sprintf('Closed slot %d against ticket %d: %s, duration %s', - $stop->id, - $stop->tid, + $stop->getId(), + $stop->getTicketId(), $stopped->getTitle(), - Formatter::formatDuration($stop->duration) + Formatter::formatDuration($stop->getDuration()) )); } try { - list($slot_id, $continued) = $this->repository->start($ticket_id, $connector_id, $input->getArgument('comment')); + $started = $this->repository->start($ticket_id, $connector_id, $input->getArgument('comment')); + $slot_id = $started->getId(); + $continued = $started->isContinued(); $output->writeln(sprintf('[%s] %s entry for %d: %s [slot:%d]', (new \DateTime())->format('h:i'), $continued ? 'Continued' : 'Started new', diff --git a/src/Commands/Status.php b/src/Commands/Status.php index 21205e8..c00bc15 100644 --- a/src/Commands/Status.php +++ b/src/Commands/Status.php @@ -62,15 +62,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $table->setHeaders(['Slot', 'JobId', 'Time', 'Title']); $rows = []; $total = 0; + /** @var \Larowlan\Tl\Slot $record */ foreach ($data as $record) { - $record->duration = ($record->end ?: time()) - $record->start; - $total += $record->duration; - $details = $this->connector->ticketDetails($record->tid, $record->connector_id); - if (empty($record->end)) { - $record->tid .= ' *'; - } - $duration = sprintf('%s', $details->isBillable() ? 'default' : 'yellow', Formatter::formatDuration($record->duration)); - $rows[] = [$record->id, $record->tid, $duration, $details->getTitle()]; + $total += $record->getDuration(); + $details = $this->connector->ticketDetails($record->getTicketId(), $record->getConnectorId()); + $duration = sprintf('%s', $details->isBillable() ? 'default' : 'yellow', Formatter::formatDuration($record->getDuration())); + $rows[] = [$record->getId(), $record->isOpen() ? sprintf('%s *', $record->getTicketId()) : $record->getTicketId(), $duration, $details->getTitle()]; } $rows[] = new TableSeparator(); $rows[] = ['', 'Total', '' . Formatter::formatDuration($total) . '', '']; diff --git a/src/Commands/Stop.php b/src/Commands/Stop.php index a2575a0..404efd7 100644 --- a/src/Commands/Stop.php +++ b/src/Commands/Stop.php @@ -54,17 +54,17 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output) { if ($stop = $this->repository->stop()) { - $stopped = $this->connector->ticketDetails($stop->tid, $stop->connector_id); + $stopped = $this->connector->ticketDetails($stop->getTicketId(), $stop->getConnectorId()); $output->writeln(sprintf('[%s] Closed slot %d against ticket %d: %s, duration %s', (new \DateTime())->format('h:i'), - $stop->id, - $stop->tid, + $stop->getId(), + $stop->getTicketId(), $stopped->getTitle(), - Formatter::formatDuration($stop->duration) + Formatter::formatDuration($stop->getDuration()) )); if (($comment = $input->getOption('comment')) || $input->getOption('pause')) { - if ($this->connector->pause($stop->tid, $comment, $stop->connector_id)) { - $output->writeln(sprintf('Ticket %s set to paused.', $stop->tid)); + if ($this->connector->pause($stop->getTicketId(), $comment, $stop->getConnectorId())) { + $output->writeln(sprintf('Ticket %s set to paused.', $stop->getTicketId())); } else { $output->writeln('Could not update ticket status'); diff --git a/src/Commands/Tag.php b/src/Commands/Tag.php index 0637e78..21da249 100644 --- a/src/Commands/Tag.php +++ b/src/Commands/Tag.php @@ -84,21 +84,28 @@ protected function tagAll(InputInterface $input, OutputInterface $output) { $output->writeln('You are offline, please try again later.'); return; } + /** @var \Larowlan\Tl\Slot $entry */ foreach ($entries as $entry) { - $categories = $grouped_categories[$entry->connector_id]; - if ($entry->category && !$input->getOption('retag')) { + $categories = $grouped_categories[$entry->getConnectorId()]; + if ($entry->getCategory() && !$input->getOption('retag')) { continue; } - $title = $this->connector->ticketDetails($entry->tid, $entry->connector_id); + if (count($categories) === 1) { + $tag = reset($categories); + list(, $tag) = explode(':', $tag); + $this->repository->tag($tag, $entry->getId()); + continue; + } + $title = $this->connector->ticketDetails($entry->getTicketId(), $entry->getConnectorId()); $default = reset($categories); $question = new ChoiceQuestion( sprintf('Enter tag for slot %d [%d]: %s [%s h] [%s] %s', - $entry->id, - $entry->tid, + $entry->getId(), + $entry->getTicketId(), $title->getTitle(), - $entry->duration, + $entry->getDuration(FALSE, TRUE) / 3600, $last ?: $default, - $entry->comment ? '- "' . $entry->comment . '"' : '' + $entry->getComment() ? '- "' . $entry->getComment() . '"' : '' ), $categories, $last ?: $default @@ -106,7 +113,7 @@ protected function tagAll(InputInterface $input, OutputInterface $output) { $tag_id = $helper->ask($input, $output, $question); $tag = $categories[$tag_id]; list(, $tag) = explode(':', $tag); - $this->repository->tag($tag, $entry->id); + $this->repository->tag($tag, $entry->getId()); $last = $tag_id; } if (!$last) { @@ -125,23 +132,29 @@ protected function tagOne(InputInterface $input, OutputInterface $output, $slot_ if ($entry = $this->repository->slot($slot_id)) { $helper = $this->getHelper('question'); try { - $title = $this->connector->ticketDetails($entry->tid, $entry->connector_id); + $title = $this->connector->ticketDetails($entry->getTicketId(), $entry->getConnectorId()); $grouped_categories = $this->connector->fetchCategories(); - $categories = $grouped_categories[$entry->connector_id]; + $categories = $grouped_categories[$entry->getConnectorId()]; } catch (ConnectException $e) { $output->writeln('You are offline, please try again later.'); return; } + if (count($categories) === 1) { + $tag = reset($categories); + list(, $tag) = explode(':', $tag); + $this->repository->tag($tag, $entry->getId()); + return; + } $default = reset($categories); $question = new ChoiceQuestion( sprintf('Enter tag for slot %d [%d]: %s [%s h] [%s] %s', - $entry->id, - $entry->tid, + $entry->getId(), + $entry->getTicketId(), $title->getTitle(), - Formatter::formatDuration($entry->end - $entry->start), + Formatter::formatDuration($entry->getDuration()), $default, - $entry->comment ? '- "' . $entry->comment . '"' : '' + $entry->getComment() ? '- "' . $entry->getComment() . '"' : '' ), $categories, $default @@ -149,7 +162,7 @@ protected function tagOne(InputInterface $input, OutputInterface $output, $slot_ $tag_id = $helper->ask($input, $output, $question); $tag = $categories[$tag_id]; list(, $tag) = explode(':', $tag); - $this->repository->tag($tag, $entry->id); + $this->repository->tag($tag, $entry->getId()); } else { $output->writeln('No such slot - please check your slot ID'); diff --git a/src/Commands/TagAll.php b/src/Commands/TagAll.php index 2a0c34a..aa1a50e 100644 --- a/src/Commands/TagAll.php +++ b/src/Commands/TagAll.php @@ -57,6 +57,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $categories = $this->connector->fetchCategories(); $tags = []; foreach ($connector_ids as $connector_id) { + if (count($categories[$connector_id]) === 1) { + $tag = reset($categories); + list(, $tag) = explode(':', $tag); + $tags[$connector_id] = $tag; + continue; + } $question = new ChoiceQuestion( sprintf('Select tag to use for %s tickets', Manager::formatConnectorId($connector_id)), $categories[$connector_id] @@ -67,11 +73,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $tags[$connector_id] = $tag; } $tagged = FALSE; + /** @var \Larowlan\Tl\Slot $entry */ foreach ($entries as $entry) { - if ($entry->category) { + if ($entry->getCategory()) { continue; } - $this->repository->tag($tags[$entry->connector_id], $entry->id); + $this->repository->tag($tags[$entry->getConnectorId()], $entry->getId()); $tagged = TRUE; } if (!$tagged) { diff --git a/src/Commands/Visit.php b/src/Commands/Visit.php index a428057..4d26e89 100644 --- a/src/Commands/Visit.php +++ b/src/Commands/Visit.php @@ -52,14 +52,14 @@ protected function configure() { protected function execute(InputInterface $input, OutputInterface $output) { if (!($issue_number = $input->getArgument('issue'))) { if ($data = $this->repository->getActive()) { - $issue_number = $data->tid; + $issue_number = $data->getTicketId(); } } if (!$issue_number) { $output->writeln('No active ticket, please use tl visit {ticket_id} to specifiy a ticket.'); return; } - $url = $this->connector->ticketUrl($issue_number, isset($data) ? $data->connector_id : $this->getConnector($input, $output, $issue_number)); + $url = $this->connector->ticketUrl($issue_number, isset($data) ? $data->getConnectorId() : $this->getConnector($input, $output, $issue_number)); $this->open($url, $output); } diff --git a/src/Connector/Connector.php b/src/Connector/Connector.php index e65e939..0bc8de5 100644 --- a/src/Connector/Connector.php +++ b/src/Connector/Connector.php @@ -2,6 +2,8 @@ namespace Larowlan\Tl\Connector; +use Larowlan\Tl\Slot; + /** * An interface for backend connectors. */ @@ -22,11 +24,13 @@ public static function getName(); * The ticket id from the remote system. * @param string $connectorId * Connector ID. + * @param bool $for_reporting + * TRUE if for reporting sake. * * @return \Larowlan\Tl\TicketInterface * Ticket object. */ - public function ticketDetails($id, $connectorId); + public function ticketDetails($id, $connectorId, $for_reporting = FALSE); /** * Fetch the details of time categories from a remote ticketing system. @@ -39,14 +43,14 @@ public function fetchCategories(); /** * Send a time entry to the remote ticketing system. * - * @param object $entry + * @param \Larowlan\Tl\Slot $entry * A time entry corresponding with a record in the {bot_tl_slot} table. * * @return int|null * The time entry id from the remote system or NULL if the entry was not * able to be saved. */ - public function sendEntry($entry); + public function sendEntry(Slot $entry); /** * Gets the URL for the given ticket ID. diff --git a/src/Connector/JiraConnector.php b/src/Connector/JiraConnector.php index df417f6..33b6481 100644 --- a/src/Connector/JiraConnector.php +++ b/src/Connector/JiraConnector.php @@ -12,6 +12,7 @@ use JiraRestApi\JiraException; use JiraRestApi\Project\ProjectService; use Larowlan\Tl\Configuration\ConfigurableService; +use Larowlan\Tl\Slot; use Larowlan\Tl\Ticket; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Console\Helper\QuestionHelper; @@ -223,7 +224,7 @@ public static function getName() { /** * {@inheritdoc} */ - public function ticketDetails($id, $connectorId) { + public function ticketDetails($id, $connectorId, $for_reporting = FALSE) { try { $issue = $this->issueService->get($id); } @@ -273,18 +274,19 @@ public function fetchCategories() { /** * {@inheritdoc} */ - public function sendEntry($entry) { - if ((float) $entry->duration == 0) { + public function sendEntry(Slot $entry) { + if ((float) $entry->getDuration(FALSE, TRUE) == 0) { // Zero time after rounding. // Return 0 to ensure doesn't send again. return 0; } $worklog = new Worklog(); - $worklog->setComment($entry->comment) - ->setStartedDateTime(new \DateTime('@' . $entry->start)) - ->setTimeSpent(sprintf('%sh %sm', floor($entry->duration), ($entry->duration - floor($entry->duration)) * 60)); - $ret = $this->issueService->addWorklog($entry->tid, $worklog); + $duration = $entry->getDuration(FALSE, TRUE) / 3600; + $worklog->setComment($entry->getComment()) + ->setStartedDateTime(new \DateTime('@' . $entry->getStart())) + ->setTimeSpent(sprintf('%sh %sm', floor($duration), ($duration - floor($duration)) * 60)); + $ret = $this->issueService->addWorklog($entry->getTicketId(), $worklog); return $ret->id; } diff --git a/src/Connector/Manager.php b/src/Connector/Manager.php index 2060e65..839bfe3 100644 --- a/src/Connector/Manager.php +++ b/src/Connector/Manager.php @@ -4,6 +4,8 @@ use Doctrine\Common\Cache\Cache; use Larowlan\Tl\Configuration\ConfigurableService; +use Larowlan\Tl\Reporter\Manager as RepoterManager; +use Larowlan\Tl\Slot; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Console\Helper\QuestionHelper; @@ -42,6 +44,13 @@ class Manager implements ConfigurableService, ConnectorManager { */ protected $version; + /** + * Reporter. + * + * @var \Larowlan\Tl\Reporter\Manager + */ + private $reporter; + /** * {@inheritdoc} */ @@ -52,7 +61,7 @@ public static function getName() { /** * {@inheritdoc} */ - public function __construct(ContainerBuilder $container, Cache $cache, array $config, $version) { + public function __construct(ContainerBuilder $container, Cache $cache, array $config, $version, RepoterManager $reporter) { if (!empty($config['connector_ids'])) { foreach ($config['connector_ids'] as $id) { $this->connectors[$id] = $container->get($id); @@ -60,6 +69,7 @@ public function __construct(ContainerBuilder $container, Cache $cache, array $co } $this->cache = $cache; $this->version = $version; + $this->reporter = $reporter; } /** @@ -113,12 +123,12 @@ public function spotConnector($id, InputInterface $input, OutputInterface $outpu /** * {@inheritdoc} */ - public function ticketDetails($id, $connectorId) { - if (($details = $this->cache->fetch($this->version . ':' . $connectorId . ':' . $id))) { + public function ticketDetails($id, $connectorId, $for_reporting = FALSE) { + if (($details = $this->cache->fetch($this->version . ':' . $connectorId . ':' . $id . ':' . $for_reporting))) { return $details; } - $ticket = $this->connector($connectorId)->ticketDetails($id, $connectorId); - $this->cache->save($this->version . ':' . $connectorId . ':' . $id, $ticket, static::LIFETIME); + $ticket = $this->connector($connectorId)->ticketDetails($id, $connectorId, $for_reporting); + $this->cache->save($this->version . ':' . $connectorId . ':' . $id . ':' . $for_reporting, $ticket, static::LIFETIME); return $ticket; } @@ -136,8 +146,18 @@ public function fetchCategories() { /** * {@inheritdoc} */ - public function sendEntry($entry) { - return $this->connector($entry->connector_id)->sendEntry($entry); + public function sendEntry(Slot $entry) { + $connector = $this->connector($entry->getConnectorId()); + if ($sendEntry = $connector->sendEntry($entry)) { + $details = $connector->ticketDetails($entry->getTicketId(), $entry->getConnectorId(), TRUE); + $projects = $connector->projectNames(); + $categories = $connector->fetchCategories(); + if ($this->reporter->report($entry, $details, $projects, $categories)) { + return $sendEntry; + } + throw new \Exception('Could not complete reporting'); + } + return $sendEntry; } /** diff --git a/src/Connector/RedmineConnector.php b/src/Connector/RedmineConnector.php index d59a465..04c47ce 100644 --- a/src/Connector/RedmineConnector.php +++ b/src/Connector/RedmineConnector.php @@ -7,6 +7,7 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ConnectException; use Larowlan\Tl\Configuration\ConfigurableService; +use Larowlan\Tl\Slot; use Larowlan\Tl\Ticket; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Console\Helper\QuestionHelper; @@ -90,12 +91,16 @@ public function loadAlias($ticket_id, $connectorId) { /** * {@inheritdoc} */ - public function ticketDetails($id, $connectorId) { + public function ticketDetails($id, $connectorId, $for_reporting = FALSE) { $url = $this->url . '/issues/' . $id . '.xml'; try { if ($xml = $this->fetch($url, $this->apiKey)) { + $title = $xml->subject . ' (' . $xml->project['name'] . ')'; + if ($for_reporting) { + $title = $xml->subject; + } $entry = new Ticket( - $xml->subject . ' (' . $xml->project['name'] . ')', + $title, (string) $xml->project['id'], $this->isBillable((string) $xml->project['id']) ); @@ -144,21 +149,21 @@ public function fetchCategories() { /** * {@inheritdoc} */ - public function sendEntry($entry) { - if ((float) $entry->duration == 0) { + public function sendEntry(Slot $entry) { + if ((float) $entry->getDuration(FALSE, TRUE) == 0) { // Zero time after rounding. // Return 0 to ensure doesn't send again. return 0; } $url = $this->url . '/time_entries.xml'; - $details = $this->ticketDetails($entry->tid, $entry->connector_id); + $details = $this->ticketDetails($entry->getTicketId(), $entry->getConnectorId()); $data = [ - 'issue_id' => $entry->tid, + 'issue_id' => $entry->getTicketId(), 'project_id' => $details->getProjectId(), - 'spent_on' => date('Y-m-d', $entry->start), - 'hours' => $entry->duration, - 'activity_id' => $entry->category, - 'comments' => $entry->comment, + 'spent_on' => date('Y-m-d', $entry->getStart()), + 'hours' => $entry->getDuration(FALSE, TRUE) / 3600, + 'activity_id' => $entry->getCategory(), + 'comments' => $entry->getComment(), ]; $xml = new \SimpleXMLElement(''); foreach ($data as $key => $value) { @@ -499,7 +504,7 @@ protected function retrieveProjects($limit, $offset) { $url = sprintf($this->url . '/projects.xml?limit=%s&status=1&offset=%s', $limit, $offset); if ($xml = $this->fetch($url, $this->apiKey)) { foreach ($xml->project as $node) { - $options[(int) $node->id] = (string) $node->name . '::' . (string) $node->id; + $options[(int) $node->id] = (string) $node->name; } } return $options; diff --git a/src/Reporter/Manager.php b/src/Reporter/Manager.php new file mode 100644 index 0000000..57b99b4 --- /dev/null +++ b/src/Reporter/Manager.php @@ -0,0 +1,135 @@ +reporters[$id] = $container->get($id); + } + } + $this->cache = $cache; + $this->version = $version; + } + + /** + * {@inheritdoc} + */ + public static function getConfiguration(NodeDefinition $root_node, ContainerBuilder $container) { + foreach (array_keys($container->findTaggedServiceIds('reporter')) as $id) { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + $class::getConfiguration($root_node, $container); + } + $root_node->children() + ->arrayNode('reporter_ids') + ->prototype('scalar') + ->end() + ->end() + ->end(); + return $root_node; + } + + /** + * {@inheritdoc} + */ + public static function askPreBootQuestions(QuestionHelper $helper, InputInterface $input, OutputInterface $output, array $config, ContainerBuilder $container) { + $connectorIds = array_keys($container->findTaggedServiceIds('reporter')); + $activeIds = []; + foreach ($connectorIds as $id) { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + $name = call_user_func([$class, 'getName']); + $default = in_array($id, $config['reporter_ids']); + $question = new ConfirmationQuestion(sprintf('Do you want to use the %s reporter?[%s/%s]', $name, $default ? 'Y' : 'y', $default ? 'n' : 'N'), $default); + if ($helper->ask($input, $output, $question)) { + $activeIds[] = $id; + $config = $class::askPreBootQuestions($helper, $input, $output, $config, $container) + $config; + } + } + $config['reporter_ids'] = $activeIds; + return $config; + } + + /** + * {@inheritdoc} + */ + public function askPostBootQuestions(QuestionHelper $helper, InputInterface $input, OutputInterface $output, array $config) { + foreach ($this->reporters as $reporter) { + $config = $reporter->askPostBootQuestions($helper, $input, $output, $config); + } + return $config; + } + + /** + * {@inheritdoc} + */ + public static function getDefaults($config, ContainerBuilder $container) { + foreach (array_keys($container->findTaggedServiceIds('reporter')) as $id) { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + $class::getDefaults($config, $container); + } + $config['reporter_ids'] = []; + return $config; + } + + /** + * {@inheritdoc} + */ + public function report(Slot $entry, TicketInterface $details, array $projects, array $categories) { + $return = TRUE; + foreach ($this->reporters as $reporter) { + $return = $return && $reporter->report($entry, $details, $projects, $categories); + } + return $return; + } + + /** + * {@inheritdoc} + */ + public static function getName() { + return 'Manager'; + } + +} diff --git a/src/Reporter/Reporter.php b/src/Reporter/Reporter.php new file mode 100644 index 0000000..a9e1d24 --- /dev/null +++ b/src/Reporter/Reporter.php @@ -0,0 +1,38 @@ +cache = $cache; + $this->config = $configuration; + $this->version = $version; + $this->api = new TogglApi($configuration['toggl_token']); + } + + /** + * {@inheritdoc} + */ + public function report(Slot $entry, TicketInterface $details, array $projects, array $categories) { + $categories = array_reduce($categories, function (array $carry, $item) { + list($name, $id) = explode(':', $item); + $carry[$id] = $name; + return $carry; + }, []); + $connector_id = $entry->getConnectorId(); + list(, $connector_id) = explode('.', $connector_id); + $project_id = $this->getProjectId(trim($projects[$details->getProjectId()]), $connector_id); + $task_id = $this->getTaskId($entry->getTicketId(), $details->getTitle(), $project_id, $connector_id); + $total = $entry->getDuration(FALSE, TRUE); + $net = array_sum(array_map(function (Chunk $chunk) { + return ($chunk->getEnd() ?: time()) - $chunk->getStart(); + }, $entry->getChunks())); + $difference = $total - $net; + foreach ($entry->getChunks() as $chunk) { + $duration = ($chunk->getEnd() ?: time()) - $chunk->getStart(); + $result = $this->api->createTimeEntry([ + 'description' => $entry->getComment(), + 'tid' => $task_id, + 'start' => date('c', $chunk->getStart()), + 'billable' => $details->isBillable(), + 'duration' => $duration + $difference, + 'created_with' => 'tl', + 'tags' => [ + $categories[$entry->getCategory()], + ], + 'duronly' => TRUE, + ]); + $difference = 0; + } + return $result->id; + } + + /** + * Gets the toggl task ID - creating one if needed. + * + * @param int $tid + * Entry task ID. + * @param string $name + * Task name. + * @param int $project_id + * Toggl task ID. + * @param string $connector_id + * Connector ID. + * + * @return int + * Toggl task ID. + */ + protected function getTaskId($tid, $name, $project_id, $connector_id) { + $workspace_id = $this->config['toggl_workspace']; + $cid = sprintf('%s:%s:toggl-tasks', $this->version, $workspace_id); + if (($cache = $this->cache->fetch($cid)) && isset($cache[$project_id][$connector_id][$tid])) { + return $cache[$project_id][$connector_id][$tid]; + } + $tasks = $this->api->getProjectTasks($project_id) ?: []; + $entries = []; + foreach ($tasks as $task) { + $matches = []; + if (preg_match('/(.*) \((?jira|redmine):(?\d+)\)/', $task->name, $matches)) { + $entries[$project_id][$matches['connector']][$matches['id']] = $task->id; + } + } + if (isset($entries[$project_id][$connector_id][$tid])) { + $this->cache->save($cid, $entries, self::LIFETIME); + return $entries[$project_id][$connector_id][$tid]; + } + // Create a task. + $new = $this->api->createTask([ + 'pid' => $project_id, + 'name' => sprintf('%s (%s:%s)', $name, $connector_id, $tid), + ]); + $entries[$project_id][$connector_id][$tid] = $new->id; + $this->cache->save($cid, $entries, self::LIFETIME); + return $new->id; + } + + /** + * Gets the toggl project ID. + * + * @param string $project_name + * Project name. + * @param string $connector_id + * Connector ID. + * + * @return int + * Project ID. + */ + protected function getProjectId($project_name, $connector_id) { + $workspace_id = $this->config['toggl_workspace']; + $cid = sprintf('%s:%s:toggl-projects', $this->version, $workspace_id); + if (($cache = $this->cache->fetch($cid)) && isset($cache[$connector_id][$project_name])) { + return $cache[$connector_id][$project_name]; + } + $projects = $this->api->getWorkspaceProjects($workspace_id) ?: []; + $entries = []; + foreach ($projects as $project) { + $matches = []; + if (preg_match('/(?.*) \((?jira|redmine)\)/', $project->name, $matches)) { + $entries[$matches['connector']][$matches['name']] = $project->id; + } + } + if (isset($entries[$connector_id][$project_name])) { + $this->cache->save($cid, $entries, self::LIFETIME); + return $entries[$connector_id][$project_name]; + } + // Create a project. + $new = $this->api->createProject([ + 'wid' => $workspace_id, + 'name' => sprintf('%s (%s)', $project_name, $connector_id), + ]); + $me = $this->api->getMe(); + $this->api->createProjectUsers([ + 'pid' => $new->id, + 'uid' => implode(',', array_map(function ($item) { + return $item->uid; + }, array_filter($this->api->getWorkspaceUserRelations($workspace_id), function ($item) use ($me) { + return $item->uid !== $me->id; + }))), + 'manager' => TRUE, + ]); + $entries[$connector_id][$project_name] = $new->id; + $this->cache->save($cid, $entries, self::LIFETIME); + return $new->id; + } + + /** + * {@inheritdoc} + */ + public static function getName() { + return 'Toggl'; + } + + /** + * {@inheritdoc} + */ + public static function getConfiguration(NodeDefinition $root_node, ContainerBuilder $container) { + $root_node->children() + ->scalarNode('toggl_token') + ->defaultValue('') + ->end() + ->scalarNode('toggl_workspace') + ->defaultValue('') + ->end() + ->end(); + } + + /** + * {@inheritdoc} + */ + public static function askPreBootQuestions(QuestionHelper $helper, InputInterface $input, OutputInterface $output, array $config, ContainerBuilder $container) { + $default_token = isset($config['toggl_token']) ? $config['toggl_token'] : ''; + // Reset. + $config = [ + 'toggl_token' => '', + ] + $config; + $question = new Question(sprintf('Enter your Toggl API token: [%s]', $default_token), $default_token); + $question->setValidator(function ($value) { + if (trim($value) == '') { + throw new \Exception('The token cannot be empty'); + } + + return $value; + }); + $question->setHidden(TRUE); + $config['toggl_token'] = $helper->ask($input, $output, $question) ?: $default_token; + return $config; + } + + /** + * {@inheritdoc} + */ + public function askPostBootQuestions(QuestionHelper $helper, InputInterface $input, OutputInterface $output, array $config) { + $default_workspace = isset($config['toggl_workspace']) ? $config['toggl_workspace'] : ''; + // Reset. + $config = [ + 'toggl_workspace' => '', + ] + $config; + try { + $workspaces = $this->api->getWorkspaces(); + $choices = array_map(function ($item) { + return $item->name . ':' . $item->id; + }, $workspaces); + } + catch (\Exception $e) { + $output->writeln('Could not connect to Toggl, please check your token and that you are online'); + return $config; + } + $question = new ChoiceQuestion(sprintf('Enter the ID of your Toggle workspace: [%s]', $default_workspace), array_combine($choices, $choices), $default_workspace); + $workspace = $helper->ask($input, $output, $question) ?: $default_workspace; + list(, $id) = explode(':', $workspace); + $config['toggl_workspace'] = $id; + return $config; + } + + /** + * {@inheritdoc} + */ + public static function getDefaults($config, ContainerBuilder $container) { + return $config = [ + 'toggl_workspace' => '', + 'toggl_token' => '', + ]; + } + +} diff --git a/src/Repository/DbRepository.php b/src/Repository/DbRepository.php index c362811..4f05650 100644 --- a/src/Repository/DbRepository.php +++ b/src/Repository/DbRepository.php @@ -3,19 +3,13 @@ namespace Larowlan\Tl\Repository; use Doctrine\DBAL\Connection; +use Larowlan\Tl\Slot; /** * Repository backed by a database. */ class DbRepository implements Repository { - /** - * Array of user details keyed by irc nick. - * - * @var array - */ - protected $userDetails = []; - /** * The active database connection. * @@ -40,28 +34,31 @@ protected function qb() { /** * {@inheritdoc} */ - public function stop($slot_id = NULL) { + public function stop($slot_id = NULL): ?Slot { if ($open = $this->getActive($slot_id)) { $end = $this::requestTime(); - $this->qb()->update('slots') + $this->qb()->update('chunks') ->set('end', $end) ->where('id = :id') - ->setParameter('id', $open->id) + ->setParameter('tid', $open->getId()) + ->setParameter('id', $open->lastChunk()->getId()) ->execute(); - $open->end = $end; - $open->duration = $open->end - $open->start; + $open->lastChunk()->setEnd($end); + $open->getDuration(TRUE); return $open; } - return FALSE; + return NULL; } /** * {@inheritdoc} */ - public function getActive($slot_id = NULL) { - $q = $this->qb()->select('*') + public function getActive($slot_id = NULL): ?Slot { + $q = $this->qb() + ->select('s.id', 's.tid', 's.comment', 's.category', 's.connector_id', 's.teid') ->from('slots', 's') - ->where('s.end IS NULL'); + ->innerJoin('s', 'chunks', 'c', 'c.sid = s.id') + ->where('c.end IS NULL'); if ($slot_id) { $q = $q->andWhere('s.id = :id') ->setParameter('id', $slot_id); @@ -69,33 +66,54 @@ public function getActive($slot_id = NULL) { if ($open = $q ->execute() ->fetch(\PDO::FETCH_OBJ)) { - return $open; + return Slot::fromRecord($open, $this->chunksForSlot($open->id)); } - return FALSE; + return NULL; + } + + /** + * Gets chunks for a slot. + * + * @param int $slot_id + * Slot ID. + * + * @return array + * Chunk records. + */ + protected function chunksForSlot(int $slot_id): array { + return $this->qb()->select('*') + ->from('chunks', 'c') + ->where('c.sid = :sid') + ->setParameter('sid', $slot_id) + ->execute() + ->fetchAll(\PDO::FETCH_OBJ); } /** * {@inheritdoc} */ - public function latest() { - $q = $this->qb()->select('*') + public function latest(): ?Slot { + $q = $this->qb() + ->select('s.id', 's.tid', 's.comment', 's.category', 's.connector_id', 's.teid') ->from('slots', 's') - ->orderBy('s.end', 'DESC'); + ->innerJoin('s', 'chunks', 'c', 'c.sid = s.id') + ->orderBy('c.end', 'DESC'); if ($open = $q ->execute() ->fetch(\PDO::FETCH_OBJ)) { - return $open; + return Slot::fromRecord($open, $this->chunksForSlot($open->id)); } - return FALSE; + return NULL; } /** * {@inheritdoc} */ - public function start($ticket_id, $connectorId, $comment = '', $force_continue = FALSE) { - $continue_query = $continue = $this->qb()->select('*') + public function start($ticket_id, $connectorId, $comment = '', $force_continue = FALSE): ?Slot { + $continue_query = $this->qb()->select('*') ->from('slots', 's') ->where('s.connector_id = :connector_id') + ->where('s.teid is NULL') ->where('s.tid = :tid'); if (!$force_continue) { $continue_query->andWhere('s.comment IS NULL') @@ -110,14 +128,8 @@ public function start($ticket_id, $connectorId, $comment = '', $force_continue = ->execute() ->fetch(\PDO::FETCH_OBJ); if ((!$comment || $force_continue) && $continue) { - $this->qb()->update('slots') - ->where('id = :id') - ->setParameter('id', $continue->id) - ->set('start', $this::requestTime() + $continue->start - $continue->end) - ->set('end', ':end') - ->setParameter(':end', NULL) - ->execute(); - return [$continue->id, TRUE]; + $this->insertChunk($this::requestTime(), NULL, $continue->id); + return Slot::fromRecord($continue, $this->chunksForSlot($continue->id)); } $record = [ 'tid' => $ticket_id, @@ -130,46 +142,54 @@ public function start($ticket_id, $connectorId, $comment = '', $force_continue = $params[':comment'] = $comment; } - return [$this->insert($record, $params), FALSE]; + return $this->slot($this->insert($record, $params)); } /** * {@inheritdoc} */ - public function insert($slot, $params = []) { + public function insert($slot, $params = []): int { + $start = $slot['start']; + $end = $slot['end'] ?? NULL; + unset($slot['start'], $slot['end']); $query = $this->qb()->insert('slots') ->values($slot); foreach ($params as $key => $value) { $query->setParameter($key, $value); } $query->execute(); - return $this->connection()->lastInsertId(); + $slot_id = $this->connection()->lastInsertId(); + $this->insertChunk($start, $end, $slot_id); + return $slot_id; } /** * {@inheritdoc} */ - public function status($date = NULL) { + public function status($date = NULL): array { if (!$date) { $stamp = mktime('0', '0'); } else { $stamp = strtotime($date); } - $return = $this->qb()->select('id', 'tid', 'end', 'start', 'connector_id') - ->from('slots') - ->where('start > :start AND start < :end') + return array_map(function ($record) { + return Slot::fromRecord($record, $this->chunksForSlot($record->id)); + }, $this->qb()->select('s.id', 's.tid', 's.connector_id', 's.teid', 's.category', 's.comment') + ->from('slots', 's') + ->innerJoin('s', 'chunks', 'c', 'c.sid = s.id') + ->having('c.start > :start AND c.start < :end') + ->groupBy('s.id', 's.tid', 's.connector_id', 's.teid', 's.category', 's.comment') ->setParameter(':start', $stamp) ->setParameter(':end', $stamp + (60 * 60 * 24)) ->execute() - ->fetchAll(\PDO::FETCH_OBJ); - return $return; + ->fetchAll(\PDO::FETCH_OBJ)); } /** * {@inheritdoc} */ - public function review($date = NULL, $check = FALSE) { + public function review($date = NULL, $check = FALSE): array { if (!$date) { $stamp = mktime('0', '0'); } @@ -177,11 +197,12 @@ public function review($date = NULL, $check = FALSE) { $stamp = strtotime($date); } $query = $this->qb() - ->select('end', 'tid', 'category', 'comment', 'id', 'start', 'connector_id') - ->from('slots'); + ->select('s.id', 's.tid', 's.connector_id', 's.teid', 's.category', 's.comment', 'c.end', 'c.start', 'c.sid') + ->from('slots', 's') + ->innerJoin('s', 'chunks', 'c', 'c.sid = s.id'); $where = $this->qb()->expr()->andX( $this->qb()->expr()->isNull('teid'), - $this->qb()->expr()->gt('start', ':stamp') + $this->qb()->expr()->gt('c.start', ':stamp') ); $query->setParameter(':stamp', $stamp); if ($check) { @@ -191,18 +212,19 @@ public function review($date = NULL, $check = FALSE) { $this->qb()->expr()->isNull('category') )); } - $return = $query->where($where)->execute()->fetchAll(\PDO::FETCH_OBJ); - foreach ($return as &$row) { - $row->duration = round((($row->end ?: time()) - $row->start) / 900) * 900 / 3600; - $row->active = empty($row->end); - } - return $return; + return array_map(function (array $record) { + return Slot::fromRecord($record['record'], $record['chunks']); + }, array_reduce($query->where($where)->execute()->fetchAll(\PDO::FETCH_OBJ), function (array $carry, $record) { + $carry[$record->sid]['record'] = $record; + $carry[$record->sid]['chunks'][] = $record; + return $carry; + }, [])); } /** * {@inheritdoc} */ - public function send() { + public function send(): array { return $this->review('19780101'); } @@ -224,12 +246,37 @@ public function store($entries) { * {@inheritdoc} */ public function edit($slot_id, int $duration) { - $request_time = $this::requestTime(); - return $this->qb()->update('slots') - ->set('start', $request_time - $duration) - ->set('end', $request_time) + $slot = $this->slot($slot_id); + $chunks = $slot->getChunks(); + $existing = $slot->getDuration(); + $difference = $duration - $existing; + if ($difference < 0) { + $remove = abs($difference); + // We're reducing the total. + while ($remove) { + $chunk = array_pop($chunks); + if ($chunk->getDuration() > $remove) { + $this->qb()->update('chunks') + ->set('end', ($chunk->getEnd() ?: time()) - $remove) + ->where('id = :id') + ->setParameter(':id', $chunk->getId())->execute(); + return; + } + $remove -= $chunk->getDuration(); + $this->qb() + ->delete('chunks') + ->where('id = :id') + ->setParameter(':id', $chunk->getId()) + ->execute(); + } + return; + } + // We're increasing the total. + $chunk = $slot->lastChunk(); + return $this->qb()->update('chunks') + ->set('end', ($chunk->getEnd() ?: time()) + $difference) ->where('id = :id') - ->setParameter(':id', $slot_id)->execute(); + ->setParameter(':id', $chunk->getId())->execute(); } /** @@ -272,39 +319,51 @@ public function comment($slot_id, $comment) { /** * {@inheritdoc} */ - public function frequent() { - return $this->qb() + public function frequent(): array { + return array_map(function ($record) { + return Slot::fromRecord($record, $this->chunksForSlot($record->id)); + }, $this->qb() ->select('tid', 'connector_id') ->from('slots', 's') ->groupBy('tid') ->orderBy('COUNT(*)', 'DESC') ->setMaxResults(10) ->execute() - ->fetchAll(\PDO::FETCH_OBJ); + ->fetchAll(\PDO::FETCH_OBJ)); } /** * {@inheritdoc} */ - public function slot($slot_id) { - return $this->qb()->select('*') + public function slot($slot_id): ?Slot { + $slot = $this->qb()->select('*') ->from('slots') ->where('id = :id') ->setParameter(':id', $slot_id) ->execute() ->fetch(\PDO::FETCH_OBJ); + if ($slot) { + return Slot::fromRecord($slot, $this->chunksForSlot($slot->id)); + } + return NULL; } /** * {@inheritdoc} */ public function delete($slot_id) { - return $this->qb()->delete('slots') + $return = $this->qb()->delete('slots') ->where('id = :id') // Can't delete sent entries. ->andWhere('teid IS NULL') ->setParameter(':id', $slot_id) ->execute(); + if ($return) { + $this->qb()->delete('chunks')->where('sid = :sid') + ->setParameter('sid', $slot_id) + ->execute(); + } + return $return; } /** @@ -371,27 +430,66 @@ public function listAliases($filter = '') { /** * {@inheritdoc} */ - public function totalByTicket($start, $end = NULL) { + public function totalByTicket($start, $end = NULL): array { if (!$end) { // Some time in the future. $end = time() + 86400; } - $return = $this->qb()->select('tid', 'end', 'start', 'connector_id') - ->from('slots') - ->where('start > :start AND start < :end') + $return = $this->qb()->select('s.tid', 's.category', 's.comment', 's.connector_id', 's.teid', 'c.start', 'c.end', 'c.sid') + ->from('slots', 's') + ->innerJoin('s', 'chunks', 'c', 'c.sid = s.id') + ->where('c.start > :start AND c.start < :end') ->setParameter(':start', $start) ->setParameter(':end', $end) ->execute() ->fetchAll(\PDO::FETCH_OBJ); - $totals = []; + $ticket_map = []; foreach ($return as $row) { - $row->duration = round((($row->end ?: time()) - $row->start) / 900) * 900; - if (!isset($totals[$row->connector_id][$row->tid])) { - $totals[$row->connector_id][$row->tid] = 0; + $ticket_map[$row->sid] = $row->tid; + if (!isset($totals[$row->connector_id][$row->sid])) { + $totals[$row->connector_id][$row->sid] = 0; } - $totals[$row->connector_id][$row->tid] += $row->duration; + $totals[$row->connector_id][$row->sid] += (($row->end ?: time()) - $row->start); } - return $totals; + $aggregated = []; + foreach ($totals as $connector_id => $slots) { + $aggregated[$connector_id] = array_reduce(array_keys($slots), function (array $carry, $sid) use ($ticket_map, $slots) { + $carry[$ticket_map[$sid]] = ($carry[$ticket_map[$sid]] ?? 0) + round($slots[$sid] / 900) * 900; + return $carry; + }, []); + } + return $aggregated; + } + + /** + * Inserts a chunk. + * + * @param int $start + * Start. + * @param int $end + * End. + * @param int $slot_id + * Slot ID. + */ + protected function insertChunk(int $start, ?int $end, int $slot_id) { + $chunk = [ + 'start' => $start, + 'end' => $end, + 'sid' => $slot_id, + ]; + $this->qb()->insert('chunks')->values(array_filter($chunk))->execute(); + } + + /** + * {@inheritdoc} + */ + public function combine(Slot $slot1, Slot $slot2) { + $this->qb()->update('chunks') + ->where('sid = :sid2') + ->set('sid', ':sid1') + ->setParameter('sid2', $slot2->getId()) + ->setParameter('sid1', $slot1->getId())->execute(); + $this->delete($slot2->getId()); } } diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php index 3a86bad..b35ce60 100644 --- a/src/Repository/Repository.php +++ b/src/Repository/Repository.php @@ -2,28 +2,30 @@ namespace Larowlan\Tl\Repository; +use Larowlan\Tl\Slot; + /** * Interface for storage of slots. */ interface Repository { - public function getActive(); + public function getActive() : ?Slot; - public function latest(); + public function latest() : ?Slot; - public function stop($slot_id = NULL); + public function stop($slot_id = NULL) : ?Slot; - public function start($ticket_id, $connectorId, $comment = '', $force_continue = FALSE); + public function start($ticket_id, $connectorId, $comment = '', $force_continue = FALSE) : ?Slot; - public function insert($slot, $params = []); + public function insert($slot, $params = []) : int; - public function status($date = NULL); + public function status($date = NULL) : array; public function comment($slot_id, $comment); - public function review($date = NULL, $check = FALSE); + public function review($date = NULL, $check = FALSE) : array; - public function send(); + public function send() : array; public function store($entries); @@ -31,9 +33,11 @@ public function edit($slot_id, int $duration); public function tag($tag_id, $slot_id = NULL); - public function frequent(); + public function frequent() : array; + + public function slot($slot_id) : ?Slot; - public function slot($slot_id); + public function combine(Slot $slot1, Slot $slot2); public function delete($slot_id); @@ -45,6 +49,6 @@ public function loadAlias($alias); public function listAliases($filter = ''); - public function totalByTicket($start_timestamp, $end_timestamp = NULL); + public function totalByTicket($start_timestamp, $end_timestamp = NULL) : array; } diff --git a/src/Repository/Schema.php b/src/Repository/Schema.php index e9a897c..a709864 100644 --- a/src/Repository/Schema.php +++ b/src/Repository/Schema.php @@ -26,18 +26,25 @@ public function getSchema() { $slots->addColumn('id', 'integer') ->setAutoincrement(TRUE); $slots->addColumn('tid', 'bigint', ['unsigned' => TRUE]); - $slots->addColumn('start', 'bigint', ['unsigned' => TRUE]); - $slots->addColumn('end', 'bigint', ['unsigned' => TRUE])->setNotnull(FALSE); $slots->addColumn('teid', 'bigint', ['unsigned' => TRUE])->setNotnull(FALSE); $slots->addColumn('comment', 'string', ['length' => 255])->setNotnull(FALSE); $slots->addColumn('category', 'string', ['length' => 255])->setNotnull(FALSE); $slots->addColumn('connector_id', 'string', ['length' => 50])->setDefault('connector.redmine')->setNotnull(FALSE); $slots->setPrimaryKey(['id']); - $slots->addIndex(['start']); - $slots->addIndex(['end']); $slots->addIndex(['tid']); $slots->addIndex(['teid']); + $chunks = $schema->createTable('chunks'); + $chunks->addColumn('id', 'integer') + ->setAutoincrement(TRUE); + $chunks->addColumn('sid', 'bigint', ['unsigned' => TRUE]); + $chunks->addColumn('start', 'bigint', ['unsigned' => TRUE]); + $chunks->addColumn('end', 'bigint', ['unsigned' => TRUE])->setNotnull(FALSE); + $chunks->setPrimaryKey(['id']); + $chunks->addIndex(['start']); + $chunks->addIndex(['end']); + $chunks->addIndex(['sid']); + $aliases = $schema->createTable('aliases'); $aliases->addColumn('tid', 'bigint', ['unsigned' => TRUE]); $aliases->addColumn('alias', 'string', ['length' => 255]); diff --git a/src/Reviewer.php b/src/Reviewer.php index 29de822..d671c7a 100644 --- a/src/Reviewer.php +++ b/src/Reviewer.php @@ -96,36 +96,37 @@ public function getSummary($date = 19780101, $check = FALSE, $exact = FALSE) { $offline = TRUE; } $exact_total = 0; + /** @var \Larowlan\Tl\Slot $record */ foreach ($data as $record) { - $total += $record->duration; - $details = $this->connector->ticketDetails($record->tid, $record->connector_id); - $category_id = str_pad($record->category, 3, 0, STR_PAD_LEFT); + $total += $record->getDuration(FALSE, TRUE); + $details = $this->connector->ticketDetails($record->getTicketId(), $record->getConnectorId()); + $category_id = str_pad($record->getCategory(), 3, 0, STR_PAD_LEFT); $category = ''; - if ($record->category) { + if ($record->getCategory()) { if ($offline) { $category = 'Offline'; } - elseif (isset($categories[$record->connector_id][$category_id])) { - $category = $categories[$record->connector_id][$category_id]; + elseif (isset($categories[$record->getConnectorId()][$category_id])) { + $category = $categories[$record->getConnectorId()][$category_id]; } else { $category = 'Unknown'; } } - $duration = sprintf('%s', $details->isBillable() ? 'default' : 'yellow', $record->duration); + $duration = sprintf('%s', $details->isBillable() ? 'default' : 'yellow', $record->getDuration(FALSE, TRUE) / 3600); $row = [ - $record->id, - sprintf('%s', $record->active ? 'green' : 'default', $record->tid), + $record->getId(), + sprintf('%s', $record->isOpen() ? 'green' : 'default', $record->getTicketId()), $duration, ]; if ($exact) { - $row[] = Formatter::formatDuration(($record->end ?: time()) - $record->start); - $exact_total += (($record->end ?: time()) - $record->start); + $row[] = Formatter::formatDuration($record->getDuration()); + $exact_total += $record->getDuration(); } $row = array_merge($row, [ substr($details->getTitle(), 0, 25) . '...', $category, - $record->comment, + $record->getComment(), ]); $rows[] = $row; } @@ -134,7 +135,7 @@ public function getSummary($date = 19780101, $check = FALSE, $exact = FALSE) { $rows[] = [ '', 'Total', - '' . $total . ' h', + '' . $total / 3600 . ' h', '' . Formatter::formatDuration($exact_total) . '', '', '', @@ -145,7 +146,7 @@ public function getSummary($date = 19780101, $check = FALSE, $exact = FALSE) { $rows[] = [ '', 'Total', - '' . $total . ' h', + '' . $total / 3600 . ' h', '', '', '', diff --git a/src/Slot.php b/src/Slot.php new file mode 100644 index 0000000..e09e066 --- /dev/null +++ b/src/Slot.php @@ -0,0 +1,278 @@ +id; + } + + /** + * Gets value of Start. + * + * @return int + * Value of Start. + */ + public function getStart(): int { + $this->sortChunks(); + return (int) reset($this->chunks)->getStart(); + } + + /** + * Gets current timestamp. + * + * @return int + * Current timestamp. + */ + protected function now() { + return (new \DateTime())->getTimestamp(); + } + + /** + * Gets value of End. + * + * @return int + * Value of End. + */ + public function getEnd(): ?int { + $this->sortChunks(); + return end($this->chunks)->getEnd() ?: $this->now(); + } + + /** + * Check if the slot is open. + * + * @return bool + * TRUE if open + */ + public function isOpen() : bool { + $this->sortChunks(); + return !end($this->chunks)->getEnd(); + } + + /** + * Gets value of RemoteEntryId. + * + * @return int + * Value of RemoteEntryId. + */ + public function getRemoteEntryId(): ?int { + return $this->remoteEntryId; + } + + /** + * Gets value of Comment. + * + * @return string + * Value of Comment. + */ + public function getComment(): ?string { + return $this->comment; + } + + /** + * Gets value of Category. + * + * @return string + * Value of Category. + */ + public function getCategory(): ?string { + return $this->category; + } + + /** + * Gets value of ConnectorId. + * + * @return string + * Value of ConnectorId. + */ + public function getConnectorId(): string { + return $this->connectorId; + } + + /** + * Is the chunk active. + * + * @return bool + * TRUE if it is active. + */ + public function isActive(): bool { + return !$this->lastChunk()->getEnd(); + } + + /** + * Gets the last slot. + */ + public function lastChunk() { + $this->sortChunks(); + return end($this->chunks); + } + + /** + * Gets value of Duration. + * + * @param bool $reset + * TRUE to reset. + * @param bool $rounded + * TRUE to round. + * + * @return int + * Value of Duration. + */ + public function getDuration($reset = FALSE, $rounded = FALSE): int { + if (!$this->duration || $reset) { + $this->duration = array_sum(array_map(function (Chunk $chunk) { + return ($chunk->getEnd() ?: $this->now()) - $chunk->getStart(); + }, $this->chunks)); + } + return $rounded ? round($this->duration / 900) * 900 : $this->duration; + } + + /** + * Gets value of ProjectName. + * + * @return string + * Value of ProjectName. + */ + public function getProjectName(): string { + return $this->projectName; + } + + /** + * Sorts the chunks. + */ + protected function sortChunks(): void { + uasort($this->chunks, function (Chunk $a, Chunk $b) { + return $a->getStart() <=> $b->getStart(); + }); + } + + /** + * Is this slot continued. + * + * @return bool + * TRUE if continued. + */ + public function isContinued() : bool { + return count($this->chunks) > 1; + } + + /** + * Creates a slot from DB records. + * + * @param object $record + * DB Record. + * @param array $chunk_records + * Chunk records. + * + * @return \Larowlan\Tl\Slot + * Slot. + */ + public static function fromRecord($record, array $chunk_records) : Slot { + $static = new static(); + $static->id = $record->id; + $static->ticketId = $record->tid; + $static->comment = $record->comment; + $static->category = $record->category; + $static->connectorId = $record->connector_id; + $static->remoteEntryId = $record->teid; + $static->chunks = array_map(function ($chunk) { + return new Chunk($chunk->id, $chunk->start, $chunk->end); + }, $chunk_records); + return $static; + } + + /** + * Gets value of TicketId. + * + * @return int + * Value of TicketId. + */ + public function getTicketId(): int { + return $this->ticketId; + } + + /** + * Gets value of Chunks. + * + * @return \Larowlan\Tl\Chunk[] + * Value of Chunks. + */ + public function getChunks(): array { + return $this->chunks; + } + +} diff --git a/tests/Commands/AddTest.php b/tests/Commands/AddTest.php index ade4c02..4027155 100644 --- a/tests/Commands/AddTest.php +++ b/tests/Commands/AddTest.php @@ -2,6 +2,7 @@ namespace Larowlan\Tl\Tests\Commands; +use Larowlan\Tl\Slot; use Larowlan\Tl\Tests\TlTestBase; use Larowlan\Tl\Ticket; @@ -19,23 +20,19 @@ class AddTest extends TlTestBase { * @covers ::execute */ public function testAdd() { - $this->getMockConnector()->expects($this->any()) - ->method('ticketDetails') - ->with(1234, 'connector.jira') - ->willReturn(new Ticket('Running tests', 123)); - $this->getMockConnector()->expects($this->any()) - ->method('spotConnector') - ->willReturn('connector.jira'); + $this->setupConnector(); + $now = new \DateTime(); $output = $this->executeCommand('add', [ 'issue_number' => 1234, 'duration' => .25, ]); - $now = new \DateTime(); $this->assertRegExp('/' . $now->format('Y-m-d h:i') . '/', $output->getDisplay()); $this->assertRegExp('/Added entry for 1234: Running tests/', $output->getDisplay()); $this->assertRegExp('/for 15:00 m/', $output->getDisplay()); $slot = $this->assertSlotAdded(1234); - $this->assertEquals((int) $now->format('U') + .25 * 60 *60, $slot->end); + $this->assertEquals(.25 * 60 *60, $slot->getDuration()); + $this->assertEquals((int) $now->format('U'), $slot->getStart()); + } /** @@ -45,17 +42,18 @@ public function testAdd() { */ public function testAddWithComment() { $this->setupConnector(); + $now = new \DateTime(); $output = $this->executeCommand('add', [ 'issue_number' => 1234, 'duration' => '1h', 'comment' => 'Doing stuff', ]); - $now = new \DateTime(); $this->assertRegExp('/' . $now->format('Y-m-d h:i') . '/', $output->getDisplay()); $this->assertRegExp('/Added entry for 1234: Running tests/', $output->getDisplay()); $this->assertRegExp('/for 1:00:00/', $output->getDisplay()); $slot = $this->assertSlotAdded(1234, 'Doing stuff'); - $this->assertEquals((int) $now->format('U') + 1 * 60 *60, $slot->end); + $this->assertEquals(1 * 60 *60, $slot->getDuration()); + $this->assertEquals((int) $now->format('U'), $slot->getStart()); } /** * Tests add command with start params. @@ -63,25 +61,20 @@ public function testAddWithComment() { * @covers ::execute */ public function testAddInPast() { + $this->setupConnector(); $start = '11 am'; - $this->getMockConnector()->expects($this->any()) - ->method('ticketDetails') - ->with(1234, 'connector.jira') - ->willReturn(new Ticket('Running tests', 123)); - $this->getMockConnector()->expects($this->any()) - ->method('spotConnector') - ->willReturn('connector.jira'); + $time = new \DateTime($start); $output = $this->executeCommand('add', [ 'issue_number' => 1234, 'duration' => 3.25, '--start' => $start, ]); - $time = new \DateTime($start); $this->assertRegExp('/' . $time->format('Y-m-d h:i') . '/', $output->getDisplay()); $this->assertRegExp('/Added entry for 1234: Running tests/', $output->getDisplay()); $this->assertRegExp('/for 3:15:00./', $output->getDisplay()); $slot = $this->assertSlotAdded(1234); - $this->assertEquals((int) $time->format('U') + 3.25 * 60 *60, $slot->end); + $this->assertEquals(3.25 * 60 *60, $slot->getDuration()); + $this->assertEquals((int) $time->format('U'), $slot->getStart()); } /** @@ -92,17 +85,18 @@ public function testAddInPast() { * @param string $comment * (Optional) Comment. * - * @return object + * @return \Larowlan\Tl\Slot + * Slot. */ - protected function assertSlotAdded($ticket_id, $comment = NULL) { + protected function assertSlotAdded($ticket_id, $comment = NULL) : Slot { /** @var \Larowlan\Tl\Repository\Repository $repository */ $repository = $this->getRepository(); $slot = $repository->latest(); - $this->assertEquals($ticket_id, $slot->tid); - $this->assertEquals($comment, $slot->comment); - $this->assertNotNull($slot->end); - $this->assertNull($slot->category); - $this->assertNull($slot->teid); + $this->assertEquals($ticket_id, $slot->getTicketId()); + $this->assertEquals($comment, $slot->getComment()); + $this->assertNotNull($slot->lastChunk()->getEnd()); + $this->assertNull($slot->getCategory()); + $this->assertNull($slot->getRemoteEntryId()); return $slot; } diff --git a/tests/Commands/ContinueTest.php b/tests/Commands/ContinueTest.php index 4b77cbe..09bd01c 100644 --- a/tests/Commands/ContinueTest.php +++ b/tests/Commands/ContinueTest.php @@ -23,9 +23,9 @@ protected function setUp() { $this->getMockConnector()->expects($this->any()) ->method('ticketDetails') ->willReturnMap([ - ['1', 'connector.redmine', new Ticket('Do something', 1)], - ['2', 'connector.redmine', new Ticket('Do something else', 2)], - ['3', 'connector.redmine', new Ticket('Do something more', 3)], + [1, 'connector.redmine', FALSE, new Ticket('Do something', 1)], + [2, 'connector.redmine', FALSE, new Ticket('Do something else', 2)], + [3, 'connector.redmine', FALSE, new Ticket('Do something more', 3)], ]); $this->getMockConnector()->expects($this->any()) ->method('spotConnector') @@ -73,8 +73,8 @@ public function testContinueCommand() { $this->assertTicketIsOpen(3); $this->getRepository()->stop(); $slot = $this->getRepository()->slot($this->slotId3); - $this->assertGreaterThanOrEqual(3600 * 9, $slot->end - $slot->start); - $this->assertLessThanOrEqual((3600 * 9) + 60, $slot->end - $slot->start); + $this->assertGreaterThanOrEqual(3600 * 9, $slot->getDuration()); + $this->assertLessThanOrEqual((3600 * 9) + 60, $slot->getDuration()); } /** @@ -87,8 +87,8 @@ public function testContinueCommandWithSlotId() { $this->assertTicketIsOpen(2); $this->getRepository()->stop(); $slot = $this->getRepository()->slot($this->slotId2); - $this->assertGreaterThanOrEqual(3600, $slot->end - $slot->start); - $this->assertLessThanOrEqual(3600 + 60, $slot->end - $slot->start); + $this->assertGreaterThanOrEqual(3600, $slot->getDuration()); + $this->assertLessThanOrEqual(3600 + 60, $slot->getDuration()); } } diff --git a/tests/Commands/EditTest.php b/tests/Commands/EditTest.php new file mode 100644 index 0000000..7f8ab81 --- /dev/null +++ b/tests/Commands/EditTest.php @@ -0,0 +1,114 @@ +getMockConnector()->expects($this->any()) + ->method('ticketDetails') + ->willReturnMap([ + [1, 'connector.redmine', FALSE, new Ticket('Do something', 1)], + [2, 'connector.redmine', FALSE, new Ticket('Do something else', 2)], + [3, 'connector.redmine', FALSE, new Ticket('Do something more', 3)], + ]); + $this->getMockConnector()->expects($this->any()) + ->method('spotConnector') + ->willReturn('connector.redmine'); + $repository = $this->getRepository(); + // Five entries for today. + $this->start = time(); + // 7 hrs. + $repository->insert([ + 'tid' => 1, + 'start' => $this->start, + 'end' => $this->start + 3588 * 7, + 'connector_id' => ':connector_id', + ], [':connector_id' => 'connector.redmine']); + // Add another chunk, 1 hour long. + $this->slot = $repository->start(1, 'connector.redmine'); + $repository->stop($this->slot->getId()); + } + + /** + * Test the basic functionality of the edit command. + */ + public function testEditIncreaseCommand() { + $this->setUp(); + $result = $this->executeCommand('edit', [ + 'slot_id' => $this->slot->getId(), + 'duration' => '8h', + ]); + $this->assertContains(sprintf('Updated slot %d to 8:00:00', $this->slot->getId()), $result->getDisplay()); + $slots = $this->getRepository()->review(); + $total = array_reduce($slots, function (int $carry, Slot $slot) { + return $carry + $slot->getDuration(); + }, 0); + $this->assertEquals(8 * 3600, $total); + } + + /** + * Test the basic functionality of the edit command. + */ + public function testEditDecreaseCommand() { + $this->setUp(); + $result = $this->executeCommand('edit', [ + 'slot_id' => $this->slot->getId(), + 'duration' => '6h', + ]); + $this->assertContains(sprintf('Updated slot %d to 6:00:00', $this->slot->getId()), $result->getDisplay()); + $slots = $this->getRepository()->review(); + $total = array_reduce($slots, function (int $carry, Slot $slot) { + return $carry + $slot->getDuration(); + }, 0); + $this->assertEquals(6 * 3600, $total); + } + + /** + * Test the basic functionality of the edit command when ticket is open + */ + public function testEditWhileOpenCommand() { + $this->setUp(); + $slot = $this->getRepository()->start(2, 'connector.redmine'); + $slots = $this->getRepository()->review(); + $this->assertCount(1, end($slots)->getChunks()); + $result = $this->executeCommand('edit', [ + 'slot_id' => $slot->getId(), + 'duration' => .25, + ]); + $this->assertContains(sprintf('Updated slot %d to 15:00 m', $slot->getId()), $result->getDisplay()); + $slots = $this->getRepository()->review(); + $total = array_reduce($slots, function (int $carry, Slot $theSlot) use ($slot) { + return $carry + $theSlot->getId() === $slot->getId() ? $theSlot->getDuration() : 0; + }, 0); + $this->assertEquals(0.25 * 3600, $total); + $this->assertCount(1, end($slots)->getChunks()); + } + +} diff --git a/tests/Commands/ReviewTest.php b/tests/Commands/ReviewTest.php index 3b8e12b..22d1609 100644 --- a/tests/Commands/ReviewTest.php +++ b/tests/Commands/ReviewTest.php @@ -19,9 +19,9 @@ protected function setUp() { $this->getMockConnector()->expects($this->any()) ->method('ticketDetails') ->willReturnMap([ - ['1', 'connector.redmine', new Ticket('Do something', 1)], - ['2', 'connector.redmine', new Ticket('Do something else ', 2)], - ['3', 'connector.redmine', new Ticket('Do something more', 3)], + [1, 'connector.redmine', FALSE, new Ticket('Do something', 1)], + [2, 'connector.redmine', FALSE, new Ticket('Do something else ', 2)], + [3, 'connector.redmine', FALSE, new Ticket('Do something more', 3)], ]); $this->getMockConnector()->expects($this->any()) ->method('spotConnector') diff --git a/tests/Commands/StartTest.php b/tests/Commands/StartTest.php index fe1d088..50a09f2 100644 --- a/tests/Commands/StartTest.php +++ b/tests/Commands/StartTest.php @@ -57,9 +57,8 @@ public function testStopStart() { $this->getMockConnector()->expects($this->any()) ->method('ticketDetails') ->willReturnMap([ - [1234, 'connector.redmine', new Ticket('Running tests', 123)], - ["1234", 'connector.redmine', new Ticket('Running tests', 123)], - [4567, 'connector.redmine', new Ticket('Running more tests', 123)], + [1234, 'connector.redmine', FALSE, new Ticket('Running tests', 123)], + [4567, 'connector.redmine', FALSE, new Ticket('Running more tests', 123)], ]); $this->getMockConnector()->expects($this->any()) ->method('spotConnector') @@ -67,14 +66,14 @@ public function testStopStart() { $output = $this->executeCommand('start', ['issue_number' => 1234]); $this->assertRegExp('/Started new entry for 1234: Running tests/', $output->getDisplay()); $active = $this->assertTicketIsOpen(1234); - $slot_id = $active->id; + $slot_id = $active->getId(); $output = $this->executeCommand('start', ['issue_number' => 4567]); $this->assertRegExp('/Closed slot [0-9]+ against ticket 1234/', $output->getDisplay()); $this->assertRegExp('/Started new entry for 4567: Running more tests/', $output->getDisplay()); $this->assertTicketIsOpen('4567'); $closed = $this->getRepository()->slot($slot_id); - $this->assertNotNull($closed->end); - $this->assertEquals('1234', $closed->tid); + $this->assertFalse($closed->isOpen()); + $this->assertEquals('1234', $closed->getTicketId()); } /** diff --git a/tests/TlTestBase.php b/tests/TlTestBase.php index ea6b225..109f33e 100644 --- a/tests/TlTestBase.php +++ b/tests/TlTestBase.php @@ -4,6 +4,7 @@ use Larowlan\Tl\Application; use Larowlan\Tl\Connector\ConnectorManager; +use Larowlan\Tl\Slot; use Larowlan\Tl\Tests\Commands\AliasTest; use Larowlan\Tl\Ticket; use PHPUnit\Framework\TestCase; @@ -67,7 +68,7 @@ protected function setUp() { if ($this->installSchema) { $install = $container->get('app.command.install'); $install->setApplication($this->application); - $input = new ArrayInput(['command' => 'install']); + $input = new ArrayInput(['command' => 'install', '--skip-post' => TRUE]); $output = $this->createMock(OutputInterface::class); $install->run($input, $output); } @@ -129,17 +130,17 @@ protected function executeCommand($name, array $input = []) { } /** - * @return mixed + * @return \Larowlan\Tl\Slot */ - protected function assertTicketIsOpen($ticket_id, $comment = NULL) { + protected function assertTicketIsOpen($ticket_id, $comment = NULL) : Slot { /** @var \Larowlan\Tl\Repository\Repository $repository */ $repository = $this->getRepository(); $active = $repository->getActive(); - $this->assertEquals($ticket_id, $active->tid); - $this->assertEquals($comment, $active->comment); - $this->assertNull($active->end); - $this->assertNull($active->category); - $this->assertNull($active->teid); + $this->assertEquals($ticket_id, $active->getTicketId()); + $this->assertEquals($comment, $active->getComment()); + $this->assertNull($active->lastChunk()->getEnd()); + $this->assertNull($active->getCategory()); + $this->assertNull($active->getRemoteEntryId()); return $active; }