/** * Prepare the response. * * @return mixed * * @since 1.0 * @throws \Exception */ protected function prepareResponse() { $this->getContainer()->get('app')->getUser()->authorize('create'); $comment = $this->getContainer()->get('app')->input->get('text', '', 'raw'); $issue_number = $this->getContainer()->get('app')->input->getInt('issue_number'); if (!$issue_number) { throw new \Exception('No issue number received.'); } if (!$comment) { throw new \Exception('You should write a comment first...'); } // @todo removeMe :( $comment .= sprintf('<br /><br />*This comment was created with the <a href="%1$s">%2$s Application</a> at <a href="%3$s">%4$s</a>.*', 'https://github.com/joomla/jissues', 'J!Tracker', $this->getContainer()->get('app')->get('uri')->base->full, $this->getContainer()->get('app')->get('uri')->base->full); $project = $this->getContainer()->get('app')->getProject(); /* @type \Joomla\Github\Github $github */ $github = $this->getContainer()->get('gitHub'); $data = new \stdClass(); $db = $this->getContainer()->get('db'); if ($project->gh_user && $project->gh_project) { $gitHubResponse = $github->issues->comments->create($project->gh_user, $project->gh_project, $issue_number, $comment); if (!isset($gitHubResponse->id)) { throw new \Exception('Invalid response from GitHub'); } $data->created_at = $gitHubResponse->created_at; $data->opened_by = $gitHubResponse->user->login; $data->comment_id = $gitHubResponse->id; $data->text_raw = $gitHubResponse->body; $data->text = $github->markdown->render($comment, 'gfm', $project->gh_user . '/' . $project->gh_project); } else { $date = new Date(); $data->created_at = $date->format($db->getDateFormat()); $data->opened_by = $this->getContainer()->get('app')->getUser()->username; $data->comment_id = '???'; $data->text_raw = $comment; $data->text = $github->markdown->render($comment, 'markdown'); } $table = new ActivitiesTable($db); $table->event = 'comment'; $table->created_date = $data->created_at; $table->project_id = $project->project_id; $table->issue_number = $issue_number; $table->gh_comment_id = $data->comment_id; $table->user = $data->opened_by; $table->text = $data->text; $table->text_raw = $data->text_raw; $table->store(); $data->activities_id = $table->activities_id; $this->response->data = $data; $this->response->message = g11n3t('Your comment has been submitted'); }
/** * Method to update data for an issue from GitHub * * @param integer $id The comment ID * * @return boolean True on success * * @since 1.0 */ protected function updateComment($id) { // Try to render the comment with GitHub markdown $parsedText = $this->parseText($this->hookData->comment->body); // Only update fields that may have changed, there's no API endpoint to show that so make some guesses $data = array(); $data['activities_id'] = $id; $data['text'] = $parsedText; $data['text_raw'] = $this->hookData->comment->body; try { $table = new ActivitiesTable($this->db); $table->load(array('activities_id' => $id)); $table->save($data); } catch (\Exception $e) { $this->logger->error('Error updating the database for comment ' . $id . ':' . $e->getMessage()); $this->getContainer()->get('app')->close(); } $this->triggerEvent('onCommentAfterUpdate', $table); // Store was successful, update status $this->logger->info(sprintf('Updated comment %s/%s #%d to the tracker.', $this->project->gh_user, $this->project->gh_project, $id)); return true; }
/** * Method to process the list of issues and inject into the database as needed * * @return $this * * @since 1.0 * @throws \UnexpectedValueException */ protected function processData() { if (!$this->items) { $this->logOut(g11n3t('Everything is up to date.')); return $this; } /* @type \Joomla\Database\DatabaseDriver $db */ $db = $this->getContainer()->get('db'); $query = $db->getQuery(true); $this->out(g11n3t('Adding events to the database...'), false); $progressBar = $this->getProgressBar(count($this->items)); $this->usePBar ? $this->out() : null; $adds = 0; $count = 0; // Initialize our ActivitiesTable instance to insert the new record $table = new ActivitiesTable($db); foreach ($this->items as $issueId => $events) { $this->usePBar ? null : $this->out(sprintf(' #%d (%d/%d)...', $issueId, $count + 1, count($this->items)), false); foreach ($events as $event) { switch ($event->event) { case 'referenced': case 'closed': case 'reopened': case 'assigned': case 'merged': case 'head_ref_deleted': case 'head_ref_restored': $query->clear()->select($table->getKeyName())->from($db->quoteName('#__activities'))->where($db->quoteName('gh_comment_id') . ' = ' . (int) $event->id)->where($db->quoteName('project_id') . ' = ' . (int) $this->project->project_id); $db->setQuery($query); $id = (int) $db->loadResult(); $table->reset(); $table->{$table->getKeyName()} = null; if ($id && !$this->force) { if ($this->force) { // Force update $this->usePBar ? null : $this->out('F', false); $table->{$table->getKeyName()} = $id; } else { // If we have something already, then move on to the next item $this->usePBar ? null : $this->out('-', false); continue; } } else { $this->usePBar ? null : $this->out('+', false); } // Translate GitHub event names to "our" name schema $evTrans = array('referenced' => 'reference', 'closed' => 'close', 'reopened' => 'reopen', 'assigned' => 'assign', 'merged' => 'merge', 'head_ref_deleted' => 'head_ref_deleted', 'head_ref_restored' => 'head_ref_restored'); $table->gh_comment_id = $event->id; $table->issue_number = $issueId; $table->project_id = $this->project->project_id; $table->user = $event->actor->login; $table->event = $evTrans[$event->event]; $table->created_date = (new Date($event->created_at))->format('Y-m-d H:i:s'); if ('referenced' == $event->event) { // @todo obtain referenced information /* $reference = $this->github->issues->events->get( $this->project->gh_user, $this->project->gh_project, $event->id ); $this->checkGitHubRateLimit($this->github->issues->events->getRateLimitRemaining()); */ } if ('assigned' == $event->event) { $reference = $this->github->issues->events->get($this->project->gh_user, $this->project->gh_project, $event->id); $table->text_raw = 'Assigned to ' . $reference->issue->assignee->login; $table->text = $table->text_raw; $this->checkGitHubRateLimit($this->github->issues->events->getRateLimitRemaining()); } $table->store(); ++$adds; break; case 'mentioned': case 'subscribed': case 'unsubscribed': continue; default: $this->logOut(sprintf('ERROR: Unknown Event: %s', $event->event)); continue; } } ++$count; $this->usePBar ? $progressBar->update($count) : null; } $this->out()->outOK()->logOut(sprintf(g11n3t('Added %d new issue events to the database'), $adds)); return $this; }
/** * Add a new event and store it to the database. * * @param string $event The event name. * @param string $dateTime Date and time. * @param string $userName User name. * @param integer $projectId Project id. * @param integer $itemNumber THE item number. * @param integer $commentId The comment id * @param string $text The parsed html comment text. * @param string $textRaw The raw comment text. * * @return $this * * @since 1.0 */ protected function addActivityEvent($event, $dateTime, $userName, $projectId, $itemNumber, $commentId = null, $text = '', $textRaw = '') { $data = array(); $date = new Date($dateTime); $data['created_date'] = $date->format($this->db->getDateFormat()); $data['event'] = $event; $data['user'] = $userName; $data['project_id'] = (int) $projectId; $data['issue_number'] = (int) $itemNumber; $data['gh_comment_id'] = (int) $commentId; $data['text'] = $text; $data['text_raw'] = $textRaw; try { $activity = new ActivitiesTable($this->db); $activity->save($data); } catch (\Exception $exception) { $this->logger->info(sprintf('Error storing %s activity to the database (ProjectId: %d, ItemNo: %d): %s', $event, $projectId, $itemNumber, $exception->getMessage())); $this->getContainer()->get('app')->close(); } return $this; }
/** * Get an item. * * @param integer $identifier The item identifier. * * @return IssuesTable * * @since 1.0 * @throws \RuntimeException */ public function getItem($identifier) { if (!$identifier) { throw new \RuntimeException('No id given'); } $item = $this->db->setQuery($this->db->getQuery(true)->select('i.*')->from($this->db->quoteName('#__issues', 'i'))->where($this->db->quoteName('i.project_id') . ' = ' . (int) $this->getProject()->project_id)->where($this->db->quoteName('i.issue_number') . ' = ' . (int) $identifier)->select($this->db->quoteName('s.status', 'status_title'))->select($this->db->quoteName('s.closed', 'closed'))->leftJoin($this->db->quoteName('#__status', 's') . ' ON ' . $this->db->quoteName('i.status') . ' = ' . $this->db->quoteName('s.id'))->select('a1.title AS rel_title, a1.status AS rel_status')->join('LEFT', '#__issues AS a1 ON i.rel_number = a1.issue_number AND a1.project_id = ' . (int) $this->getProject()->project_id)->select('s1.closed AS rel_closed')->join('LEFT', '#__status AS s1 ON a1.status = s1.id')->select('t.name AS rel_name')->join('LEFT', '#__issues_relations_types AS t ON i.rel_type = t.id')->select('m.title AS milestone_title')->join('LEFT', '#__tracker_milestones AS m ON m.milestone_id = i.milestone_id')->select('u.id AS user_id')->leftJoin('#__users AS u ON i.opened_by = u.username'))->loadObject(); if (!$item) { throw new \RuntimeException('Invalid Issue', 1); } // Fetch activities $table = new ActivitiesTable($this->db); $query = $this->db->getQuery(true); $query->select('a.*'); $query->from($this->db->quoteName($table->getTableName(), 'a')); $query->where($this->db->quoteName('a.project_id') . ' = ' . (int) $this->getProject()->project_id); $query->where($this->db->quoteName('a.issue_number') . ' = ' . (int) $item->issue_number); $query->order($this->db->quoteName('a.created_date')); $activityData = $this->db->setQuery($query)->loadObjectList(); $commits = json_decode($item->commits) ?: []; $activities = []; // Store the last commit to fetch the test results later $lastCommit = end($commits); foreach ($activityData as $i => $activity) { foreach ($commits as $i1 => $commit) { $d1 = new \DateTime($commit->committer_date); $d2 = new \DateTime($activity->created_date, new \DateTimeZone('UTC')); if ($d1 < $d2) { $m = explode("\n", $commit->message); $a = new \stdClass(); $a->event = 'commit'; $a->user = $commit->author_name; $a->text = $m[0]; $a->created_date = $commit->committer_date; $a->activities_id = $commit->sha; $activities[] = $a; unset($commits[$i1]); } } $activities[] = $activity; } $item->activities = $activities; // Fetch foreign relations $item->relations_f = $this->db->setQuery($this->db->getQuery(true)->from($this->db->quoteName('#__issues', 'a'))->join('LEFT', '#__issues_relations_types AS t ON a.rel_type = t.id')->join('LEFT', '#__status AS s ON a.status = s.id')->select('a.issue_number, a.title, a.rel_type')->select('t.name AS rel_name')->select('s.status AS status_title, s.closed AS closed')->where($this->db->quoteName('a.rel_number') . '=' . (int) $item->issue_number)->order(array('a.issue_number', 'a.rel_type')))->loadObjectList(); // Group relations by type if ($item->relations_f) { $arr = array(); foreach ($item->relations_f as $relation) { if (false == isset($arr[$relation->rel_name])) { $arr[$relation->rel_name] = array(); } $arr[$relation->rel_name][] = $relation; } $item->relations_f = $arr; } // Fetch the voting data $query->clear()->select('COUNT(id) AS votes, SUM(experienced) AS experienced, SUM(score) AS score')->from($this->db->quoteName('#__issues_voting'))->where($this->db->quoteName('issue_number') . ' = ' . (int) $item->id); $voteData = $this->db->setQuery($query)->loadObject(); $item->votes = $voteData->votes; $item->experienced = $voteData->experienced; $item->score = $voteData->score; // Set the score if we have votes if ($item->votes > 0) { $item->importanceScore = $item->score / $item->votes; } else { $item->importanceScore = 0; } // Decode the merge status $item->gh_merge_status = json_decode($item->gh_merge_status); // Fetch test data if ($lastCommit) { $item->testsSuccess = $this->db->setQuery($query->clear()->select('username')->from($this->db->quoteName('#__issues_tests'))->where($this->db->quoteName('item_id') . ' = ' . (int) $item->id)->where($this->db->quoteName('result') . ' = 1')->where($this->db->quoteName('sha') . ' = ' . $this->db->quote($lastCommit->sha)))->loadColumn(); sort($item->testsSuccess); $item->testsFailure = $this->db->setQuery($query->clear()->select('username')->from($this->db->quoteName('#__issues_tests'))->where($this->db->quoteName('item_id') . ' = ' . (int) $item->id)->where($this->db->quoteName('result') . ' = 2')->where($this->db->quoteName('sha') . ' = ' . $this->db->quote($lastCommit->sha)))->loadColumn(); sort($item->testsFailure); } // Fetch category $item->categories = $this->db->setQuery($query->clear()->select('a.title, a.id, a.color, a.alias')->from($this->db->quoteName('#__issues_categories', 'a'))->innerJoin($this->db->quoteName('#__issue_category_map', 'b') . ' ON b.category_id = a.id')->where('b.issue_id =' . (int) $item->id))->loadObjectList(); return $item; }
/** * Compute the changes. * * @return $this Method allows chaining * * @since 1.0 */ private function processChanges() { $changes = array(); foreach ($this as $fName => $field) { if (!$this->{$fName} && !$this->oldObject->{$fName}) { // Both values are "empty" continue; } if ($this->{$fName} != $this->oldObject->{$fName}) { $change = new \stdClass(); $change->name = $fName; $change->old = $this->oldObject->{$fName}; $change->new = $this->{$fName}; switch ($fName) { case 'modified_date': case 'modified_by': // Expected change ;) break; case 'description': // Do nothing break; default: $changes[] = $change; break; } } } if ($changes) { $data = array(); $data['event'] = 'change'; $data['created_date'] = $this->modified_date; $data['user'] = $this->modified_by; $data['issue_number'] = (int) $this->issue_number; $data['project_id'] = (int) $this->project_id; $data['text'] = json_encode($changes); $table = new ActivitiesTable($this->db); $table->save($data); } return $this; }
/** * Process the change in category for issues. * * @param array $src The source, should include: $src['issue_number'], the issue's number; $src['project_id'], * the issue's project id; $src['old'] and $src['new'] for old and new categories; $src['modified_by'], * modified username. * * @since 1.0 * * @return $this */ private function processChanges(array $src) { $date = new Date(); $date = $date->format($this->getDb()->getDateFormat()); $change = new \stdClass(); $change->name = 'category'; $change->old = array(); $change->new = array(); foreach ($src['old'] as $key => $old) { $oldCategory = $this->getItem($old); $change->old[$key]['title'] = $oldCategory->title; $change->old[$key]['color'] = $oldCategory->color; } foreach ($src['new'] as $key => $new) { $newCategory = $this->getItem($new); $change->new[$key]['title'] = $newCategory->title; $change->new[$key]['color'] = $newCategory->color; } $data = array(); $data['event'] = 'change'; $data['created_date'] = $date; $data['user'] = $src['modified_by']; $data['issue_number'] = (int) $src['issue_number']; $data['project_id'] = (int) $src['project_id']; $data['text'] = json_encode(array($change)); $table = new ActivitiesTable($this->getDb()); $table->save($data); return $this; }
/** * Execute the controller. * * @return string The rendered view. * * @since 1.0 * @throws \JTracker\Authentication\Exception\AuthenticationException * @throws \RuntimeException * @throws \UnexpectedValueException */ public function execute() { /* @type \JTracker\Application $application */ $application = $this->getContainer()->get('app'); $src = $application->input->get('item', array(), 'array'); $user = $application->getUser(); $project = $application->getProject(); $model = new IssueModel($this->getContainer()->get('db')); $model->setProject($project); $issueNumber = isset($src['issue_number']) ? (int) $src['issue_number'] : 0; if (!$issueNumber) { throw new \UnexpectedValueException('No issue number received.'); } $item = $model->getItem($issueNumber); $data = array(); if ($user->check('edit')) { // The user has full "edit" permission. $data = $src; // Allow admins to update labels and milestones if (!$user->check('manage')) { if (!empty($item->labels)) { $data['labels'] = explode(',', $item->labels); } $data['milestone_id'] = $item->milestone_id; } } elseif ($user->canEditOwn($item->opened_by)) { // The user has "edit own" permission. $data['id'] = (int) $src['id']; $data['issue_number'] = (int) $src['issue_number']; $data['title'] = $src['title']; $data['description_raw'] = $src['description_raw']; // Take the remaining values from the stored item if (!empty($item->labels)) { $data['labels'] = explode(',', $item->labels); } $data['status'] = $item->status; $data['priority'] = $item->priority; $data['build'] = $item->build; $data['rel_number'] = $item->rel_number; $data['rel_type'] = $item->rel_type; $data['easy'] = $item->easy; $data['milestone_id'] = $item->milestone_id; } else { // The user has no "edit" permission. throw new AuthenticationException($user, 'edit'); } $gitHub = GithubFactory::getInstance($application); // Check if the state has changed (e.g. open/closed) $oldState = $model->getOpenClosed($item->status); $state = $model->getOpenClosed($data['status']); // Project is managed on GitHub if ($project->gh_user && $project->gh_project) { // @todo assignee $assignee = null; // Prepare labels $ghLabels = []; if (!empty($data['labels'])) { foreach ($project->getLabels() as $id => $label) { if (in_array($id, $data['labels'])) { $ghLabels[] = $label->name; } } } // Prepare milestone $ghMilestone = null; if (!empty($data['milestone_id'])) { foreach ($project->getMilestones() as $milestone) { if ($milestone->milestone_id == $data['milestone_id']) { $ghMilestone = $milestone->milestone_number; } } } try { $gitHubResponse = $this->updateGitHub($item->issue_number, $data, $state, $oldState, $assignee, $ghMilestone, $ghLabels); // Set the modified_date from GitHub (important!) $data['modified_date'] = $gitHubResponse->updated_at; } catch (GithubException $exception) { $this->getContainer()->get('app')->getLogger()->error(sprintf('Error code %1$s received from GitHub when editing an issue with the following data:' . ' GitHub User: %2$s; GitHub Repo: %3$s; Issue Number: %4$s; State: %5$s, Old state: %6$s' . ' The error message returned was: %7$s', $exception->getCode(), $project->gh_user, $project->gh_project, $item->issue_number, $state, $oldState, $exception->getMessage())); throw new \RuntimeException('Invalid response from GitHub'); } // Render the description text using GitHub's markdown renderer. $data['description'] = $gitHub->markdown->render($data['description_raw'], 'gfm', $project->gh_user . '/' . $project->gh_project); } else { // Project is managed by JTracker only // Render the description text using GitHub's markdown renderer. $data['description'] = $gitHub->markdown->render($src['description_raw'], 'markdown'); $data['modified_date'] = (new Date())->format($this->getContainer()->get('db')->getDateFormat()); } try { $data['modified_by'] = $user->username; // If the user have edit permission, let him / her modify the categories. if ($user->check('edit')) { $categoryModel = new CategoryModel($this->getContainer()->get('db')); $category['issue_id'] = $data['id']; $category['modified_by'] = $user->username; $category['categories'] = $application->input->get('categories', null, 'array'); $category['issue_number'] = $data['issue_number']; $category['project_id'] = $project->project_id; $categoryModel->updateCategory($category); } // Pass the old and new states into the save method $data['old_state'] = $oldState; $data['new_state'] = $state; // Values that are not supposed to change. $data['commits'] = $item->commits; $data['pr_head_sha'] = $item->pr_head_sha; // Save the record. $model->save($data); $comment = $application->input->get('comment', '', 'raw'); // Save the comment. if ($comment) { /* @type \JTracker\Github\Github $github */ $github = $this->getContainer()->get('gitHub'); $project = $application->getProject(); $gitHubHelper = new GitHubHelper($github); $comment .= $gitHubHelper->getApplicationComment($application, $project, $issueNumber); $data = new \stdClass(); $db = $this->getContainer()->get('db'); if ($project->gh_user && $project->gh_project) { $gitHubResponse = $github->issues->comments->create($project->gh_user, $project->gh_project, $issueNumber, $comment); if (!isset($gitHubResponse->id)) { throw new \RuntimeException('Invalid response from GitHub'); } $data->created_at = $gitHubResponse->created_at; $data->opened_by = $gitHubResponse->user->login; $data->comment_id = $gitHubResponse->id; $data->text_raw = $gitHubResponse->body; $data->text = $github->markdown->render($comment, 'gfm', $project->gh_user . '/' . $project->gh_project); } else { $date = new Date(); $data->created_at = $date->format($db->getDateFormat()); $data->opened_by = $application->getUser()->username; $data->comment_id = '???'; $data->text_raw = $comment; $data->text = $github->markdown->render($comment, 'markdown'); } $table = new ActivitiesTable($db); $table->event = 'comment'; $table->created_date = $data->created_at; $table->project_id = $project->project_id; $table->issue_number = $issueNumber; $table->gh_comment_id = $data->comment_id; $table->user = $data->opened_by; $table->text = $data->text; $table->text_raw = $data->text_raw; $table->store(); } $application->enqueueMessage('The changes have been saved.', 'success')->redirect('/tracker/' . $application->input->get('project_alias') . '/' . $issueNumber); } catch (\RuntimeException $exception) { $application->enqueueMessage($exception->getMessage(), 'error'); // @todo preserve data when returning to edit view on failure. $application->redirect($application->get('uri.base.path') . 'tracker/' . $application->input->get('project_alias') . '/' . $issueNumber . '/edit'); } return parent::execute(); }
/** * Method to process the list of issues and inject into the database as needed * * @return $this * * @since 1.0 */ protected function processData() { if (!$this->items) { $this->logOut(g11n3t('Everything is up to date.')); return $this; } /* @type \Joomla\Database\DatabaseDriver $db */ $db = $this->getContainer()->get('db'); // Initialize our query object $query = $db->getQuery(true); $this->out(sprintf(g11n4t('Processing comments for one modified issue...', 'Processing comments for %d modified issues...', count($this->items)), count($this->items))); $adds = 0; $updates = 0; $count = 1; // Initialize our ActivitiesTable instance to insert the new record $table = new ActivitiesTable($db); // Comments ids for computing the difference $commentsIds = array(); // Comments ids to delete $toDelete = array(); // Start processing the comments now foreach ($this->items as $issueNumber => $comments) { if (!count($comments)) { $this->out()->out(sprintf(g11n3t('No comments for issue # %d'), $issueNumber)); } else { $this->out()->out(sprintf(g11n4t('Processing one comment for issue # %2$d (%3$d/%4$d)', 'Processing %1$d comments for issue # %2$d (%3$d/%4$d)', count($comments)), count($comments), $issueNumber, $count, count($this->items))); $progressBar = $this->getProgressBar(count($comments)); $this->usePBar ? $this->out() : null; foreach ($comments as $i => $comment) { // Store the comment id for computing the difference $commentsIds[] = $comment->id; $check = $db->setQuery($query->clear()->select($table->getKeyName())->select($db->quoteName('updated_date'))->from($db->quoteName($table->getTableName()))->where($db->quoteName('gh_comment_id') . ' = ' . (int) $comment->id)->where($db->quoteName('project_id') . ' = ' . (int) $this->project->project_id))->loadObject(); if ($check) { if (!$this->force) { // If we have something already, check if it needs an update... $d1 = new Date($check->updated_date); $d2 = new Date($comment->updated_at); if ($d1 == $d2) { // No update required $this->usePBar ? $progressBar->update($i + 1) : $this->out('-', false); continue; } } $table->load($check->{$table->getKeyName()}); $this->usePBar ? null : $this->out($this->force ? 'F ' : '~ ', false); } else { // New item $table->reset(); $table->{$table->getKeyName()} = null; $this->usePBar ? null : $this->out('+', false); } $table->gh_comment_id = $comment->id; $table->issue_number = (int) $issueNumber; $table->project_id = $this->project->project_id; $table->user = $comment->user->login; $table->event = 'comment'; $table->text_raw = $comment->body; $table->text = $this->github->markdown->render($comment->body, 'gfm', $this->project->gh_user . '/' . $this->project->gh_project); $this->checkGitHubRateLimit($this->github->markdown->getRateLimitRemaining()); $table->created_date = (new Date($comment->created_at))->format('Y-m-d H:i:s'); $table->updated_date = (new Date($comment->updated_at))->format('Y-m-d H:i:s'); $table->store(); if ($check) { ++$updates; } else { ++$adds; } $this->usePBar ? $progressBar->update($i + 1) : null; } ++$count; } // Compute the difference between GitHub comments and issue comments $issueComments = $this->getIssueCommentsIds($issueNumber); $commentsToDelete = array_diff($issueComments, $commentsIds); $toDelete = array_merge($toDelete, $commentsToDelete); } // Delete comments which does not exist on GitHub if (!empty($toDelete)) { $this->deleteIssuesComments($toDelete); } $this->out()->outOK()->logOut(sprintf(g11n3t('%1$d added, %2$d updated, %3$d deleted.'), $adds, $updates, count($toDelete))); return $this; }