Skip to content

Commit 3e036ff

Browse files
committed
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.
1 parent 4daf93c commit 3e036ff

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)