ChangeLogCommand::loglines()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 11
rs 10
cc 2
nc 2
nop 1
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2021 ChannelWeb Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
16
namespace BEdita\DevTools\Command;
17
18
use Cake\Command\Command;
19
use Cake\Console\Arguments;
20
use Cake\Console\ConsoleIo;
21
use Cake\Console\ConsoleOptionParser;
22
use Cake\Core\Configure;
23
use Cake\Core\InstanceConfigTrait;
24
use Cake\Http\Client;
25
use Cake\Utility\Hash;
26
27
/**
28
 * Command to generate a changelog snippet to prepare a new relase.
29
 */
30
class ChangeLogCommand extends Command
31
{
32
    use InstanceConfigTrait;
33
34
    /**
35
     * Default configuration for command.
36
     *
37
     * @var array
38
     */
39
    protected array $_defaultConfig = [
40
        // Classification filter on labels
41
        'filter' => [
42
            'integration' => [
43
                'Topic - Integration',
44
                'Topic - Tests',
45
            ],
46
            'api' => [
47
                'Topic - API',
48
            ],
49
            'core' => [
50
                'Topic - Core',
51
                'Topic - Database',
52
                'Topic - Authentication',
53
                'Topic - ORM',
54
            ],
55
        ],
56
        // Changelog sections
57
        'sections' => [
58
            'api' => 'API',
59
            'core' => 'Core',
60
            'integration' => 'Integration',
61
            'other' => 'Other',
62
        ],
63
        // Issues search url
64
        'url' => 'https://api.github.com/search/issues',
65
        // HTTP client configuration
66
        'client' => [],
67
    ];
68
69
    /**
70
     * @inheritDoc
71
     */
72
    protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
73
    {
74
        $parser->addArgument('from', [
75
                'help' => 'Read closed PRs from this date in "YYYY-MM-DD" format',
76
                'required' => true,
77
            ])
78
            ->addArgument('version', [
79
                'help' => 'Version to release',
80
                'required' => true,
81
            ]);
82
83
        return $parser;
84
    }
85
86
    /**
87
     * @inheritDoc
88
     */
89
    public function initialize(): void
90
    {
91
        /** @var array<string, mixed> $config */
92
        $config = (array)Configure::read('ChangeLog');
93
        $this->setConfig($config);
94
    }
95
96
    /**
97
     * Read closed PRs from github and create changelog file.
98
     *
99
     * @param \Cake\Console\Arguments $args The command arguments.
100
     * @param \Cake\Console\ConsoleIo $io The console io
101
     * @return int|null The exit code or null for success
102
     */
103
    public function execute(Arguments $args, ConsoleIo $io): ?int
104
    {
105
        $from = $args->getArgument('from');
106
        $io->out("Reading closed PRs from from $from");
107
108
        $json = $this->fetchPrs((string)$from);
109
        $items = (array)Hash::get($json, 'items');
110
        $version = (string)$args->getArgument('version');
111
        $items = $this->filterItems($items, $version);
112
        $io->out(sprintf('Loaded %d prs', count($items)));
113
114
        $changeLog = $this->createChangeLog($items);
115
        $this->saveChangeLog($changeLog, $version);
116
117
        $io->out('Changelog created. Bye.');
118
119
        return null;
120
    }
121
122
    /**
123
     * Filter items by Milestone: major version of item mileston should match requested version (4 o 5 for instance).
124
     *
125
     * @param array $items Changelog items
126
     * @param string $version Release version
127
     * @return array
128
     */
129
    protected function filterItems(array $items, string $version): array
130
    {
131
        $major = substr($version, 0, (int)strpos($version, '.'));
132
133
        return array_filter(
134
            $items,
135
            function ($item) use ($major) {
136
                /** @var string $milestone */
137
                $milestone = Hash::get($item, 'milestone.title');
138
                $milestone = str_replace('-', '.', $milestone);
139
                $v = substr($milestone, 0, (int)strpos($milestone, '.'));
140
141
                return $v == $major;
142
            }
143
        );
144
    }
145
146
    /**
147
     * Fetch pull requests from Github
148
     *
149
     * @param string $from From date.
150
     * @return array
151
     */
152
    protected function fetchPrs(string $from): array
153
    {
154
        /** @var array<string, mixed> $config */
155
        $config = (array)$this->getConfig('client');
156
        $client = new Client($config);
157
        $query = [
158
            'q' => sprintf('is:pr draft:false repo:bedita/bedita merged:>%s', $from),
159
            'sort' => '-closed',
160
            'per_page' => 100,
161
        ];
162
        $headers = ['Accept' => 'application/vnd.github.v3+json'];
163
        /** @var string $url */
164
        $url = $this->getConfig('url');
165
        $response = $client->get($url, $query, compact('headers'));
166
167
        return (array)$response->getJson();
168
    }
169
170
    /**
171
     * Create changelog array with classified pull requests
172
     *
173
     * @param array $items PR items
174
     * @return array
175
     */
176
    protected function createChangeLog(array $items): array
177
    {
178
        $res = [];
179
        foreach ($items as $item) {
180
            $labels = Hash::extract($item, 'labels.{n}.name');
181
            $type = $this->classify((array)$labels);
182
            $res[$type][] = $item;
183
        }
184
185
        return $res;
186
    }
187
188
    /**
189
     * Classify PR from its labels
190
     *
191
     * @param array $labels Labels array
192
     * @return string
193
     */
194
    protected function classify(array $labels): string
195
    {
196
        foreach ((array)$this->getConfig('filter') as $name => $data) {
197
            if (!empty(array_intersect($labels, (array)$data))) {
198
                return $name;
199
            }
200
        }
201
202
        return 'other';
203
    }
204
205
    /**
206
     * Save changelog to file
207
     *
208
     * @param array $changeLog Changelog items
209
     * @param string $version Version released
210
     * @return void
211
     */
212
    protected function saveChangeLog(array $changeLog, string $version): void
213
    {
214
        $out = sprintf("## Version %s - Cactus\n", $version);
215
216
        foreach ((array)$this->getConfig('sections') as $name => $label) {
217
            /** @var string $label */
218
            $out .= sprintf("\n### %s changes (%s)\n\n", $label, (string)$version);
219
            $out .= $this->loglines((array)Hash::get($changeLog, $name));
220
        }
221
222
        file_put_contents(sprintf('changelog-%s.md', $version), $out);
223
    }
224
225
    /**
226
     * Section changelog lines
227
     *
228
     * @param array $items Changelog items
229
     * @return string
230
     */
231
    protected function loglines(array $items): string
232
    {
233
        $res = '';
234
        foreach ($items as $item) {
235
            $id = (string)$item['number'];
236
            $url = (string)$item['html_url'];
237
            $title = (string)$item['title'];
238
            $res .= sprintf("* [#%s](%s) %s\n", $id, $url, $title);
239
        }
240
241
        return $res;
242
    }
243
}
244