diff --git a/src/Command/ReleaseCommand.php b/src/Command/ReleaseCommand.php new file mode 100644 index 000000000..429f4ab3f --- /dev/null +++ b/src/Command/ReleaseCommand.php @@ -0,0 +1,307 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Command; + +use Packagist\Api\Result\Package; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +/** + * @author Jordi Sala + */ +final class ReleaseCommand extends AbstractCommand +{ + private static $labels = [ + 'patch' => 'blue', + 'bug' => 'red', + 'docs' => 'yellow', + 'minor' => 'green', + 'pedantic' => 'cyan', + ]; + + private static $stabilities = [ + 'patch' => 'blue', + 'minor' => 'green', + 'pedantic' => 'yellow', + ]; + + protected function configure(): void + { + parent::configure(); + + $help = <<<'EOT' +The release command analyzes pull request of a given project to determine +the changelog and the next version to release. + +Usage: + +php dev-kit release + +First, a question about what bundle to release will be shown, this will be autocompleted will +the projects configured on projects.yml + +The command will show what is the status of the project, then a list of pull requests +made against selected branch (default: stable branch) with the following information: + +stability, name, labels, changelog, url. + +After that, it will show what is the next version to release and the changelog for that release. +EOT; + + $this + ->setName('release') + ->setDescription('Helps with a project release.') + ->setHelp($help); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $project = $this->getProject($input, $output); + $branches = array_keys($this->configs['projects'][$project]['branches']); + $branch = \count($branches) > 1 ? next($branches) : current($branches); + + $package = $this->packagistClient->get(static::PACKAGIST_GROUP.'/'.$project); + $this->io->title($package->getName()); + $this->prepareRelease($package, $branch, $output); + + return 0; + } + + private function getProject(InputInterface $input, OutputInterface $output) + { + $helper = $this->getHelper('question'); + + $question = new Question('Please enter the name of the project to release: '); + $question->setAutocompleterValues(array_keys($this->configs['projects'])); + $question->setNormalizer(static function ($answer) { + return $answer ? trim($answer) : ''; + }); + $question->setValidator(function ($answer) { + if (!\array_key_exists($answer, $this->configs['projects'])) { + throw new \RuntimeException('The name of the project should be on `projects.yml`'); + } + + return $answer; + }); + $question->setMaxAttempts(3); + + return $helper->ask($input, $output, $question); + } + + private function prepareRelease(Package $package, $branch, OutputInterface $output): void + { + $repositoryName = $this->getRepositoryName($package); + + $currentRelease = $this->githubClient->repo()->releases()->latest( + static::GITHUB_GROUP, + $repositoryName + ); + + $branchToRelease = $this->githubClient->repo()->branches( + static::GITHUB_GROUP, + $repositoryName, + $branch + ); + + $statuses = $this->githubClient->repo()->statuses()->combined( + static::GITHUB_GROUP, + $repositoryName, + $branchToRelease['commit']['sha'] + ); + + $pulls = $this->findPullRequestsSince($currentRelease['published_at'], $repositoryName, $branch); + $nextVersion = $this->determineNextVersion($currentRelease['tag_name'], $pulls); + $changelog = array_reduce( + array_filter(array_column($pulls, 'changelog')), + 'array_merge_recursive', + [] + ); + + $this->io->section('Project'); + + foreach ($statuses['statuses'] as $status) { + $print = $status['description']."\n".$status['target_url']; + + if ('success' === $status['state']) { + $this->io->success($print); + } elseif ('pending' === $status['state']) { + $this->io->warning($print); + } else { + $this->io->error($print); + } + } + + $this->io->section('Pull requests'); + + foreach ($pulls as $pull) { + $this->printPullRequest($pull, $output); + } + + $this->io->section('Release'); + + if ($nextVersion === $currentRelease['tag_name']) { + $this->io->warning('Release is not needed'); + } else { + $this->io->success('Next release will be: '.$nextVersion); + + $this->io->section('Changelog'); + + $this->printRelease($currentRelease['tag_name'], $nextVersion, $package, $output); + $this->printChangelog($changelog, $output); + } + } + + private function printPullRequest($pull, OutputInterface $output): void + { + if (\array_key_exists($pull['stability'], static::$stabilities)) { + $output->write('[' + .strtoupper($pull['stability']).'] '); + } else { + $output->write('[NOT SET] '); + } + $output->write(''.$pull['title'].''); + + foreach ($pull['labels'] as $label) { + if (!\array_key_exists($label['name'], static::$labels)) { + $output->write(' ['.$label['name'].']'); + } else { + $output->write(' ['.$label['name'].']'); + } + } + + if (empty($pull['labels'])) { + $output->write(' [No labels]'); + } + + if (!$pull['changelog'] && 'pedantic' !== $pull['stability']) { + $output->write(' [Changelog not found]'); + } elseif (!$pull['changelog']) { + $output->write(' [Changelog not found]'); + } elseif ($pull['changelog'] && 'pedantic' === $pull['stability']) { + $output->write(' [Changelog found]'); + } else { + $output->write(' [Changelog found]'); + } + $this->io->newLine(); + $output->writeln($pull['html_url']); + $this->io->newLine(); + } + + private function printRelease($currentVersion, $nextVersion, Package $package, OutputInterface $output): void + { + $output->writeln('## ['.$nextVersion.'](' + .$package->getRepository().'/compare/'.$currentVersion.'...'.$nextVersion + .') - '.date('Y-m-d')); + } + + private function printChangelog($changelog, OutputInterface $output): void + { + ksort($changelog); + foreach ($changelog as $type => $changes) { + if (0 === \count($changes)) { + continue; + } + + $output->writeln('### '.$type); + + foreach ($changes as $change) { + $output->writeln($change); + } + $this->io->newLine(); + } + } + + private function parseChangelog($pull) + { + $changelog = []; + $body = preg_replace('//Uis', '', $pull['body']); + preg_match('/## Changelog.*```\s*markdown\s*\\n(.*)\\n```/Uis', $body, $matches); + + if (2 == \count($matches)) { + $lines = explode(PHP_EOL, $matches[1]); + + $section = ''; + foreach ($lines as $line) { + $line = trim($line); + + if (empty($line)) { + continue; + } + + if (0 === strpos($line, '#')) { + $section = preg_replace('/^#* /i', '', $line); + } elseif (!empty($section)) { + $line = preg_replace('/^- /i', '', $line); + $changelog[$section][] = '- [[#'.$pull['number'].']('.$pull['html_url'].')] '. + ucfirst($line).' ([@'.$pull['user']['login'].']('.$pull['user']['html_url'].'))'; + } + } + } + + return $changelog; + } + + private function determineNextVersion($currentVersion, $pulls) + { + $stabilities = array_column($pulls, 'stability'); + $parts = explode('.', $currentVersion); + + if (\in_array('minor', $stabilities)) { + return implode('.', [$parts[0], $parts[1] + 1, 0]); + } elseif (\in_array('patch', $stabilities)) { + return implode('.', [$parts[0], $parts[1], $parts[2] + 1]); + } + + return $currentVersion; + } + + private function determinePullRequestStability($pull) + { + $labels = array_column($pull['labels'], 'name'); + + if (\in_array('minor', $labels)) { + return 'minor'; + } elseif (\in_array('patch', $labels)) { + return 'patch'; + } elseif (array_intersect(['docs', 'pedantic'], $labels)) { + return 'pedantic'; + } + } + + private function findPullRequestsSince($date, $repositoryName, $branch) + { + $pulls = $this->githubPaginator->fetchAll($this->githubClient->search(), 'issues', [ + 'repo:'.static::GITHUB_GROUP.'/'.$repositoryName. + ' type:pr is:merged base:'.$branch.' merged:>'.$date, + ]); + + $filteredPulls = []; + foreach ($pulls as $pull) { + if ('SonataCI' === $pull['user']['login']) { + continue; + } + + $pull['changelog'] = $this->parseChangelog($pull); + $pull['stability'] = $this->determinePullRequestStability($pull); + + $filteredPulls[] = $pull; + } + + return $filteredPulls; + } +}