Skip to content

Commit c162356

Browse files
authored
Merge pull request #243 from jordisala1991/feature/release
Release command 🚀
2 parents 4daf93c + 3e036ff commit c162356

File tree

1 file changed

+307
-0
lines changed

1 file changed

+307
-0
lines changed

src/Command/ReleaseCommand.php

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Sonata Project package.
7+
*
8+
* (c) Thomas Rabaix <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace App\Command;
15+
16+
use Packagist\Api\Result\Package;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
use Symfony\Component\Console\Question\Question;
20+
21+
/**
22+
* @author Jordi Sala <[email protected]>
23+
*/
24+
final class ReleaseCommand extends AbstractCommand
25+
{
26+
private static $labels = [
27+
'patch' => 'blue',
28+
'bug' => 'red',
29+
'docs' => 'yellow',
30+
'minor' => 'green',
31+
'pedantic' => 'cyan',
32+
];
33+
34+
private static $stabilities = [
35+
'patch' => 'blue',
36+
'minor' => 'green',
37+
'pedantic' => 'yellow',
38+
];
39+
40+
protected function configure(): void
41+
{
42+
parent::configure();
43+
44+
$help = <<<'EOT'
45+
The <info>release</info> command analyzes pull request of a given project to determine
46+
the changelog and the next version to release.
47+
48+
Usage:
49+
50+
<info>php dev-kit release</info>
51+
52+
First, a question about what bundle to release will be shown, this will be autocompleted will
53+
the projects configured on <info>projects.yml</info>
54+
55+
The command will show what is the status of the project, then a list of pull requests
56+
made against selected branch (default: stable branch) with the following information:
57+
58+
stability, name, labels, changelog, url.
59+
60+
After that, it will show what is the next version to release and the changelog for that release.
61+
EOT;
62+
63+
$this
64+
->setName('release')
65+
->setDescription('Helps with a project release.')
66+
->setHelp($help);
67+
}
68+
69+
/**
70+
* {@inheritdoc}
71+
*/
72+
protected function execute(InputInterface $input, OutputInterface $output)
73+
{
74+
$project = $this->getProject($input, $output);
75+
$branches = array_keys($this->configs['projects'][$project]['branches']);
76+
$branch = \count($branches) > 1 ? next($branches) : current($branches);
77+
78+
$package = $this->packagistClient->get(static::PACKAGIST_GROUP.'/'.$project);
79+
$this->io->title($package->getName());
80+
$this->prepareRelease($package, $branch, $output);
81+
82+
return 0;
83+
}
84+
85+
private function getProject(InputInterface $input, OutputInterface $output)
86+
{
87+
$helper = $this->getHelper('question');
88+
89+
$question = new Question('<info>Please enter the name of the project to release:</info> ');
90+
$question->setAutocompleterValues(array_keys($this->configs['projects']));
91+
$question->setNormalizer(static function ($answer) {
92+
return $answer ? trim($answer) : '';
93+
});
94+
$question->setValidator(function ($answer) {
95+
if (!\array_key_exists($answer, $this->configs['projects'])) {
96+
throw new \RuntimeException('The name of the project should be on `projects.yml`');
97+
}
98+
99+
return $answer;
100+
});
101+
$question->setMaxAttempts(3);
102+
103+
return $helper->ask($input, $output, $question);
104+
}
105+
106+
private function prepareRelease(Package $package, $branch, OutputInterface $output): void
107+
{
108+
$repositoryName = $this->getRepositoryName($package);
109+
110+
$currentRelease = $this->githubClient->repo()->releases()->latest(
111+
static::GITHUB_GROUP,
112+
$repositoryName
113+
);
114+
115+
$branchToRelease = $this->githubClient->repo()->branches(
116+
static::GITHUB_GROUP,
117+
$repositoryName,
118+
$branch
119+
);
120+
121+
$statuses = $this->githubClient->repo()->statuses()->combined(
122+
static::GITHUB_GROUP,
123+
$repositoryName,
124+
$branchToRelease['commit']['sha']
125+
);
126+
127+
$pulls = $this->findPullRequestsSince($currentRelease['published_at'], $repositoryName, $branch);
128+
$nextVersion = $this->determineNextVersion($currentRelease['tag_name'], $pulls);
129+
$changelog = array_reduce(
130+
array_filter(array_column($pulls, 'changelog')),
131+
'array_merge_recursive',
132+
[]
133+
);
134+
135+
$this->io->section('Project');
136+
137+
foreach ($statuses['statuses'] as $status) {
138+
$print = $status['description']."\n".$status['target_url'];
139+
140+
if ('success' === $status['state']) {
141+
$this->io->success($print);
142+
} elseif ('pending' === $status['state']) {
143+
$this->io->warning($print);
144+
} else {
145+
$this->io->error($print);
146+
}
147+
}
148+
149+
$this->io->section('Pull requests');
150+
151+
foreach ($pulls as $pull) {
152+
$this->printPullRequest($pull, $output);
153+
}
154+
155+
$this->io->section('Release');
156+
157+
if ($nextVersion === $currentRelease['tag_name']) {
158+
$this->io->warning('Release is not needed');
159+
} else {
160+
$this->io->success('Next release will be: '.$nextVersion);
161+
162+
$this->io->section('Changelog');
163+
164+
$this->printRelease($currentRelease['tag_name'], $nextVersion, $package, $output);
165+
$this->printChangelog($changelog, $output);
166+
}
167+
}
168+
169+
private function printPullRequest($pull, OutputInterface $output): void
170+
{
171+
if (\array_key_exists($pull['stability'], static::$stabilities)) {
172+
$output->write('<fg=black;bg='.static::$stabilities[$pull['stability']].'>['
173+
.strtoupper($pull['stability']).']</> ');
174+
} else {
175+
$output->write('<error>[NOT SET]</error> ');
176+
}
177+
$output->write('<info>'.$pull['title'].'</info>');
178+
179+
foreach ($pull['labels'] as $label) {
180+
if (!\array_key_exists($label['name'], static::$labels)) {
181+
$output->write(' <error>['.$label['name'].']</error>');
182+
} else {
183+
$output->write(' <fg='.static::$labels[$label['name']].'>['.$label['name'].']</>');
184+
}
185+
}
186+
187+
if (empty($pull['labels'])) {
188+
$output->write(' <fg=black;bg=yellow>[No labels]</>');
189+
}
190+
191+
if (!$pull['changelog'] && 'pedantic' !== $pull['stability']) {
192+
$output->write(' <error>[Changelog not found]</error>');
193+
} elseif (!$pull['changelog']) {
194+
$output->write(' <fg=black;bg=green>[Changelog not found]</>');
195+
} elseif ($pull['changelog'] && 'pedantic' === $pull['stability']) {
196+
$output->write(' <fg=black;bg=yellow>[Changelog found]</>');
197+
} else {
198+
$output->write(' <fg=black;bg=green>[Changelog found]</>');
199+
}
200+
$this->io->newLine();
201+
$output->writeln($pull['html_url']);
202+
$this->io->newLine();
203+
}
204+
205+
private function printRelease($currentVersion, $nextVersion, Package $package, OutputInterface $output): void
206+
{
207+
$output->writeln('## ['.$nextVersion.']('
208+
.$package->getRepository().'/compare/'.$currentVersion.'...'.$nextVersion
209+
.') - '.date('Y-m-d'));
210+
}
211+
212+
private function printChangelog($changelog, OutputInterface $output): void
213+
{
214+
ksort($changelog);
215+
foreach ($changelog as $type => $changes) {
216+
if (0 === \count($changes)) {
217+
continue;
218+
}
219+
220+
$output->writeln('### '.$type);
221+
222+
foreach ($changes as $change) {
223+
$output->writeln($change);
224+
}
225+
$this->io->newLine();
226+
}
227+
}
228+
229+
private function parseChangelog($pull)
230+
{
231+
$changelog = [];
232+
$body = preg_replace('/<!--(.*)-->/Uis', '', $pull['body']);
233+
preg_match('/## Changelog.*```\s*markdown\s*\\n(.*)\\n```/Uis', $body, $matches);
234+
235+
if (2 == \count($matches)) {
236+
$lines = explode(PHP_EOL, $matches[1]);
237+
238+
$section = '';
239+
foreach ($lines as $line) {
240+
$line = trim($line);
241+
242+
if (empty($line)) {
243+
continue;
244+
}
245+
246+
if (0 === strpos($line, '#')) {
247+
$section = preg_replace('/^#* /i', '', $line);
248+
} elseif (!empty($section)) {
249+
$line = preg_replace('/^- /i', '', $line);
250+
$changelog[$section][] = '- [[#'.$pull['number'].']('.$pull['html_url'].')] '.
251+
ucfirst($line).' ([@'.$pull['user']['login'].']('.$pull['user']['html_url'].'))';
252+
}
253+
}
254+
}
255+
256+
return $changelog;
257+
}
258+
259+
private function determineNextVersion($currentVersion, $pulls)
260+
{
261+
$stabilities = array_column($pulls, 'stability');
262+
$parts = explode('.', $currentVersion);
263+
264+
if (\in_array('minor', $stabilities)) {
265+
return implode('.', [$parts[0], $parts[1] + 1, 0]);
266+
} elseif (\in_array('patch', $stabilities)) {
267+
return implode('.', [$parts[0], $parts[1], $parts[2] + 1]);
268+
}
269+
270+
return $currentVersion;
271+
}
272+
273+
private function determinePullRequestStability($pull)
274+
{
275+
$labels = array_column($pull['labels'], 'name');
276+
277+
if (\in_array('minor', $labels)) {
278+
return 'minor';
279+
} elseif (\in_array('patch', $labels)) {
280+
return 'patch';
281+
} elseif (array_intersect(['docs', 'pedantic'], $labels)) {
282+
return 'pedantic';
283+
}
284+
}
285+
286+
private function findPullRequestsSince($date, $repositoryName, $branch)
287+
{
288+
$pulls = $this->githubPaginator->fetchAll($this->githubClient->search(), 'issues', [
289+
'repo:'.static::GITHUB_GROUP.'/'.$repositoryName.
290+
' type:pr is:merged base:'.$branch.' merged:>'.$date,
291+
]);
292+
293+
$filteredPulls = [];
294+
foreach ($pulls as $pull) {
295+
if ('SonataCI' === $pull['user']['login']) {
296+
continue;
297+
}
298+
299+
$pull['changelog'] = $this->parseChangelog($pull);
300+
$pull['stability'] = $this->determinePullRequestStability($pull);
301+
302+
$filteredPulls[] = $pull;
303+
}
304+
305+
return $filteredPulls;
306+
}
307+
}

0 commit comments

Comments
 (0)