reviewer-1.2.x-dev/src/Drush/Commands/ReviewerCommands.php
src/Drush/Commands/ReviewerCommands.php
<?php
namespace Drupal\reviewer\Drush\Commands;
use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
use Drupal\reviewer\Plugin\reviewer\Review\ReviewPluginInterface;
use Drupal\reviewer\Plugin\ReviewManagerInterface;
use Drupal\reviewer\Reviewer\Action;
use Drupal\reviewer\Reviewer\IgnorerInterface;
use Drupal\reviewer\Reviewer\Result\ResultFactoryInterface;
use Drupal\reviewer\Reviewer\Result\ResultInterface;
use Drupal\reviewer\Reviewer\Review\ReviewRunnerInterface;
use Drupal\reviewer\Reviewer\Status\Status;
use Drupal\reviewer\Reviewer\Status\StatusEvaluatorInterface;
use Drupal\reviewer\Reviewer\Status\StatusFactoryInterface;
use Drush\Attributes as CLI;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;
use Drush\Symfony\BufferedConsoleOutput;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
/**
* Drush commands for reviewer.
*/
final class ReviewerCommands extends DrushCommands {
use AutowireTrait;
// phpcs:ignore Drupal.Commenting.FunctionComment.Missing
public function __construct(
private readonly IgnorerInterface $ignorer,
private readonly ResultFactoryInterface $resultFactory,
private readonly ReviewManagerInterface $reviewManager,
private readonly ReviewRunnerInterface $reviewRunner,
private readonly StatusEvaluatorInterface $statusEvaluator,
private readonly StatusFactoryInterface $statusFactory,
) {
parent::__construct();
}
/**
* List all available reviews.
*/
#[CLI\Command(name: 'reviewer:list', aliases: ['rvl'])]
#[CLI\Help(description: 'List available reviews.')]
#[CLI\Usage(name: 'reviewer:list', description: 'List available reviews.')]
#[CLI\DefaultTableFields(fields: ['id', 'label'])]
#[CLI\FieldLabels(labels: ['id' => 'ID', 'label' => 'Label'])]
public function list(): RowsOfFields|null {
$reviews = $this->reviewManager->createAllInstances();
if ($reviews) {
$this->io()->writeln('');
$this->io()->writeln(dt('Available Reviews:'));
return new RowsOfFields(array_map(
fn(ReviewPluginInterface $review) => ['id' => $review->getPluginId(), 'label' => $review->getLabel()],
$reviews,
));
}
$this->io()->warning(dt('No reviews available.'));
return NULL;
}
/**
* Run reviews, checking for issues.
*
* @param string[] $ids
* @param array<string, bool> $options
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
#[CLI\Command(name: 'reviewer:check', aliases: ['rvc'])]
#[CLI\Help(description: 'Run reviews, checking for issues.')]
#[CLI\Argument(name: 'ids', description: 'Review IDs to run, separated by spaces.')]
#[ClI\Option(name: 'show-passed', description: 'Display passed tests in addition to failures and errors.')]
#[ClI\Option(name: 'show-not-run', description: 'Display tests which were not run in addition to failures and errors.')]
#[ClI\Option(name: 'show-ignored', description: 'Display ignored tests in addition to failures and errors.')]
#[ClI\Option(name: 'show-all', description: 'Display ignored and passed tests in addition to failures and errors.')]
#[CLI\Usage(name: 'reviewer:run', description: 'Run all reviews.')]
#[CLI\Usage(name: 'reviewer:run node', description: 'Run the "node" review.')]
#[CLI\Usage(name: 'reviewer:run node:article,page paragraph:gallery', description: 'Run reviews for the specified "node" and "paragraph" bundles.')]
public function check(
array $ids,
array $options = [
'show-ignored' => FALSE,
'show-passed' => FALSE,
'show-not-run' => FALSE,
'show-all' => FALSE,
],
): void {
$show_ignored = $options['show-ignored'] || $options['show-all'];
$show_passed = $options['show-passed'] || $options['show-all'];
$show_not_run = $options['show-not-run'] || $options['show-all'];
foreach ($this->doRun($ids, Action::Check) as $review) {
$rows = [];
foreach ($review->getResults()->getIndividualResults() as $result) {
if ($show_passed && $this->statusEvaluator->isPass($result->getStatus())) {
$rows[] = $result;
continue;
}
if ($show_not_run && $this->statusEvaluator->isNotRun($result->getStatus())) {
$rows[] = $result;
continue;
}
if ($show_ignored && $this->ignorer->isIgnored($result)) {
$rows[] = $this->resultFactory->createResult(
$result->getId(),
$result->getStatus(),
$this->ignorer->ignoredReason($result),
);
continue;
}
if (
$this->statusEvaluator->isFailureOrError($result->getStatus())
&& !$this->ignorer->isIgnored($result)
) {
$rows[] = $result;
}
}
$this->tableFromResults($rows, $review->getLabel());
}
}
/**
* Fix failures found in reviews.
*
* @param string[] $ids
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
#[CLI\Command(name: 'reviewer:fix', aliases: ['rvf'])]
#[CLI\Help(description: 'Fix failures found by reviews. Failures marked as ignored will not be fixed.')]
#[CLI\Argument(name: 'ids', description: 'Review IDs to fix failures in, separated by spaces.')]
#[CLI\Usage(name: 'reviewer:fix', description: 'Fix failures in all reviews.')]
#[CLI\Usage(name: 'reviewer:run node', description: 'Fix failures found in the "node" review.')]
#[CLI\Usage(name: 'reviewer:run node:article,page paragraph:gallery', description: 'Fix failures found in the specified bundles of the "node" and "paragraph" reviews.')]
public function fix(array $ids): void {
$confirm = $this->io()->confirm(dt('Fixing issues will alter configuration on your site, and cannot be undone.'));
if (!$confirm) {
return;
}
foreach ($this->doRun($ids, Action::Fix) as $review) {
$results = [];
foreach ($review->getResults()->getIndividualResults() as $result) {
if ($result->getStatus() === Status::Fixed) {
$results[] = $result;
}
}
$this->tableFromResults($results, $review->getLabel());
}
}
/**
* Ignore failures and errors found in reviews.
*
* @param string[] $ids
* @param array<string, bool> $options
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
#[CLI\Command(name: 'reviewer:ignore', aliases: ['rvi'])]
#[CLI\Help(description: 'Run reviews, prompting to ignore any unignored failures and errors.')]
#[CLI\Argument(name: 'ids', description: 'Review IDs to run, separated by spaces.')]
#[CLI\Option(name: 'existing', description: 'Review and update existing ignored failures and errors as well as unignored ones.')]
#[CLI\Option(name: 'reset', description: 'Unignore previously ignored failures and errors before ignoring.')]
#[CLI\Option(name: 'unignore', description: 'Unignore previously ignored failures and errors.')]
public function ignore(
array $ids,
array $options = [
'existing' => FALSE,
'reset' => FALSE,
'unignore' => FALSE,
],
): void {
$review_existing = (bool) $options['existing'];
$do_reset = $options['reset'] || $options['unignore'];
$unignore = (bool) $options['unignore'];
$summary = new BufferedConsoleOutput(decorated: TRUE);
if ($do_reset || $unignore) {
foreach ($this->doRun($ids, Action::Check) as $review) {
$this->ignorer->unignore($review->getResults()->getIndividualResults());
}
}
if ($unignore) {
return;
}
if (!$this->input()->isInteractive()) {
throw new \RuntimeException('reviewer:ignore can only be run with the --unignore option without an interactive terminal.');
}
foreach ($this->doRun($ids, Action::Check) as $review) {
$results = array_filter(
$review->getResults()->getIndividualResults(),
fn(ResultInterface $result) => $this->statusEvaluator->isFailureOrError($result->getStatus()),
);
if ($review_existing) {
$results += array_filter(
$review->getResults()->getIndividualResults(),
fn(ResultInterface $result) => $this->statusEvaluator->isIgnored($result->getStatus()) && $this->ignorer->isConfigIgnored($result),
);
}
ksort($results);
foreach ($results as $result) {
if ($this->statusEvaluator->isPass($result->getStatus())) {
continue;
}
$is_ignored = FALSE;
$is_updated = FALSE;
$reason = '';
if (
$review_existing
&& $this->ignorer->isConfigIgnored($result)
) {
$this->tableFromResults([$result], $review->getLabel());
$reason = $this->ignorer->ignoredReason($result);
$is_ignored = $this->io()->confirm(dt('Keep ignoring result @id?' . PHP_EOL . ' Current ignored reason: @reason', [
'@id' => $result->getId(),
'@reason' => $reason,
]));
$is_updated = TRUE;
}
if (!$this->ignorer->isIgnored($result)) {
$this->tableFromResults([$result], $review->getLabel());
$is_ignored = $this->io()->confirm(
dt('Ignore result @id?', ['@id' => $result->getId()]),
FALSE,
);
$is_updated = TRUE;
}
if (!$is_updated) {
continue;
}
if ($is_ignored) {
$reason = $this->io()->ask(
dt('Provide a reason for ignoring @id.', [
'@id' => $result->getId(),
]),
$reason,
);
assert(\is_string($reason));
$this->ignorer->ignore($result, $reason);
$results[$result->getId()] = $this->resultFactory->createResult(
$result->getId(),
$this->statusFactory->createIgnored($result->getStatus()),
$reason,
);
}
else {
$this->ignorer->unignore($result);
$results[$result->getId()] = $this->resultFactory->createResult(
$result->getId(),
$this->statusFactory->createUnignored($result->getStatus()),
$result->getMessage(),
);
}
}
$this->tableFromResults($results, $review->getLabel(), $summary);
}
echo $summary->fetch();
}
/**
* Run review plugins.
*
* @param string[] $ids
* @param \Drupal\reviewer\Reviewer\Action $action
*
* @return array<string, \Drupal\reviewer\Reviewer\Review\ReviewInterface>
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
private function doRun(array $ids, Action $action): array {
$reviews = [];
if ($review_ids = $this->processReviewIds($ids)) {
foreach ($review_ids as $review_id => $bundles) {
if (!$this->reviewManager->hasDefinition($review_id)) {
$this->io()->warning(dt('Review @id does not exist.', [
'@id' => $review_id,
]));
}
$reviews = array_merge($reviews, $this->reviewRunner->runIds(
$action,
[$review_id],
$bundles,
));
}
}
else {
$reviews = $this->reviewRunner->runIds($action);
}
return $reviews;
}
/**
* Process review IDs and return an array of review IDs and their bundles.
*
* @param string[] $review_ids
*
* @return array<string, string[]>
* Array keys are the review IDs with the values the bundles for each review
* ID, with an empty array for all bundles or a review which does not
* support bundles.
*/
private function processReviewIds(array $review_ids): array {
$processed_ids = [];
foreach ($review_ids as $review_id) {
$split_ids = explode(':', $review_id);
$bundles = match (\count($split_ids)) {
1 => [],
default => explode(',', $split_ids[1]),
};
$processed_ids = array_merge(
$processed_ids,
array_fill_keys(explode(',', $split_ids[0]), $bundles),
);
}
return $processed_ids;
}
/**
* Print a table from an array of results.
*
* @param \Drupal\reviewer\Reviewer\Result\IndividualResultInterface[] $rows
*/
private function tableFromResults(
array $rows,
string $label,
ConsoleOutputInterface $output = new ConsoleOutput(),
): void {
if ($rows) {
$table = (new Table($output))
->setStyle($this->io()->createTable()->getStyle())
->setVertical()
->setColumnWidth(0, mb_strlen($label) + 4)
->setHeaderTitle($label)
->setHeaders([dt('Status'), dt('ID'), dt('Message')]);
foreach ($rows as $row) {
$table->addRow([
$this->coloredLabel($row->getStatus()),
$row->getId(),
$row->getMessage(),
]);
}
$output->writeln('');
$table->render();
$output->writeln('');
}
}
/**
* Get a colored label for a status.
*/
private function coloredLabel(Status $status): string {
$label = (string) $status->label();
return match ($status) {
Status::NotRun => "<fg=black;bg=white>$label</>",
Status::IgnoredFailure, Status::IgnoredError => "<fg=black;bg=yellow>$label</>",
Status::Pass, Status::Fixed => "<info>$label</info>",
Status::Fail, Status::Error => "<error>$label</error>",
};
}
}
