From 3e036ff3ad5aab4d42bd110032839d106bbc498a Mon Sep 17 00:00:00 2001 From: Jordi Sala Date: Fri, 17 Mar 2017 01:36:38 +0100 Subject: [PATCH] Add release command This release command with help the release manager to generate the needed changelog and check that every PR is well formed, have the correct labels, and have the needed changelog associated. --- src/Command/ReleaseCommand.php | 307 +++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 src/Command/ReleaseCommand.php 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; + } +}