1 | <?php |
||
2 | |||
3 | namespace Elgg\Project; |
||
4 | |||
5 | use Elgg\Exceptions\InvalidArgumentException; |
||
6 | |||
7 | /** |
||
8 | * Helper class to write the changelog during release |
||
9 | * |
||
10 | * @since 5.1 |
||
11 | * @internal |
||
12 | */ |
||
13 | class ChangelogWriter { |
||
14 | |||
15 | protected array $options; |
||
16 | |||
17 | protected array $commit_types = [ |
||
18 | 'feature' => 'Features', |
||
19 | 'performance' => 'Performance', |
||
20 | 'documentation' => 'Documentation', |
||
21 | 'fix' => 'Bug fixes', |
||
22 | 'deprecated' => 'Deprecations', |
||
23 | 'breaking' => 'Breaking Changes', |
||
24 | 'removed' => 'Removed', |
||
25 | ]; |
||
26 | |||
27 | /** |
||
28 | * Constructor |
||
29 | * |
||
30 | * @param array $options writer options |
||
31 | */ |
||
32 | public function __construct(array $options = []) { |
||
33 | $defaults = [ |
||
34 | 'changelog' => Paths::elgg() . 'CHANGELOG.md', |
||
35 | 'version' => null, |
||
36 | 'notes' => '', |
||
37 | 'repository' => 'https://github.com/Elgg/Elgg/', |
||
38 | ]; |
||
39 | |||
40 | $options = array_merge($defaults, $options); |
||
41 | if (empty($options['version'])) { |
||
42 | throw new InvalidArgumentException('Please provide a release version number'); |
||
43 | } |
||
44 | |||
45 | if (!file_exists($options['changelog']) || !is_writable($options['changelog'])) { |
||
46 | throw new InvalidArgumentException("The changelog file doesn't exist or is not writable"); |
||
47 | } |
||
48 | |||
49 | $this->options = $options; |
||
50 | } |
||
51 | |||
52 | /** |
||
53 | * Write the changelog for the current release |
||
54 | * |
||
55 | * @return void |
||
56 | */ |
||
57 | public function __invoke(): void { |
||
58 | $tags = $this->getGitTags(); |
||
59 | |||
60 | $sections = []; |
||
61 | |||
62 | $sections[] = $this->formatHeader(); |
||
63 | $sections[] = $this->readNotes(); |
||
64 | |||
65 | $contributors = $this->getGitContributors([ |
||
66 | 'exclude' => $tags, |
||
67 | ]); |
||
68 | $sections[] = $this->formatContributors($contributors); |
||
69 | |||
70 | $commits = $this->getGitCommits([ |
||
71 | 'exclude' => $tags, |
||
72 | ]); |
||
73 | $sections[] = $this->formatCommits($commits); |
||
74 | |||
75 | $sections = array_filter($sections); |
||
76 | $output = trim(implode(PHP_EOL . PHP_EOL, $sections)); |
||
77 | |||
78 | $this->writeChangelog($output); |
||
79 | } |
||
80 | |||
81 | /** |
||
82 | * Read anything in the changelog before the first '<a name="">' and consider this release notes |
||
83 | * |
||
84 | * @return string |
||
85 | */ |
||
86 | protected function readNotes(): string { |
||
87 | $contents = file_get_contents($this->getOption('changelog')); |
||
88 | $first_anchor = strpos($contents, '<a name="'); |
||
89 | if ($first_anchor === false) { |
||
90 | return trim($this->getOption('notes', '')); |
||
91 | } |
||
92 | |||
93 | return trim($this->getOption('notes', '') . substr($contents, 0, $first_anchor)); |
||
94 | } |
||
95 | |||
96 | /** |
||
97 | * Get the current git tags |
||
98 | * |
||
99 | * @return array |
||
100 | */ |
||
101 | protected function getGitTags(): array { |
||
102 | return $this->executeCommand('git tag') ?: []; |
||
1 ignored issue
–
show
|
|||
103 | } |
||
104 | |||
105 | /** |
||
106 | * Get all the commits |
||
107 | * |
||
108 | * @param array $options options |
||
109 | * |
||
110 | * @return array |
||
111 | */ |
||
112 | protected function getGitCommits(array $options): array { |
||
113 | $defaults = [ |
||
114 | 'grep' => '^[a-z]+(\(.*\))?:|BREAKING', |
||
115 | 'format' => '%H%n%h%n%s%n%b%n==END==', |
||
116 | 'exclude' => [], |
||
117 | 'to' => 'HEAD', |
||
118 | ]; |
||
119 | $options = array_merge($defaults, $options); |
||
120 | |||
121 | $command = vsprintf('git log --grep="%s" -E --format=%s %s %s', [ |
||
122 | $options['grep'], |
||
123 | $options['format'], |
||
124 | $options['to'], |
||
125 | implode(' ', array_map(function ($value) { |
||
126 | if (str_contains(PHP_OS, 'WIN')) { |
||
127 | return "^^{$value}"; |
||
128 | } |
||
129 | |||
130 | return "^{$value}"; |
||
131 | }, $options['exclude'])), |
||
132 | ]); |
||
133 | |||
134 | $commits = $this->executeCommand($command); |
||
1 ignored issue
–
show
Are you sure the assignment to
$commits is correct as $this->executeCommand($command) targeting Elgg\Project\ChangelogWriter::executeCommand() seems to always return null.
This check looks for function or method calls that always return null and whose return value is assigned to a variable. class A
{
function getObject()
{
return null;
}
}
$a = new A();
$object = $a->getObject();
The method The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.
Loading history...
|
|||
135 | if (!isset($commits)) { |
||
136 | return []; |
||
137 | } |
||
138 | |||
139 | $results = []; |
||
140 | $result = [ |
||
141 | 'body' => '', |
||
142 | ]; |
||
143 | $index = 0; |
||
144 | $subject_pattern = '/^((Merge )|(Revert )|((\w*)\(([\w]+)\)\: ([^\n]*))$)/'; |
||
145 | foreach ($commits as $line) { |
||
146 | if ($line === '==END==') { |
||
147 | $result['body'] = trim($result['body'] ?: '', PHP_EOL); |
||
148 | |||
149 | $results[] = $result; |
||
150 | $index = 0; |
||
151 | $result = [ |
||
152 | 'body' => '', |
||
153 | ]; |
||
154 | continue; |
||
155 | } |
||
156 | |||
157 | switch ($index) { |
||
158 | case 0: // long hash |
||
159 | $result['hash'] = $line; |
||
160 | break; |
||
161 | |||
162 | case 1: // short hash |
||
163 | $result['short_hash'] = $line; |
||
164 | break; |
||
165 | |||
166 | case 2: // subject |
||
167 | $matches = []; |
||
168 | preg_match($subject_pattern, $line, $matches); |
||
169 | |||
170 | $result['type'] = $matches[5] ?? 'skip'; |
||
171 | $result['component'] = $matches[6] ?? ''; |
||
172 | $result['subject'] = $matches[7] ?? ''; |
||
173 | break; |
||
174 | |||
175 | default: // the rest of the commit body |
||
176 | if (empty($line)) { |
||
177 | break; |
||
178 | } |
||
179 | |||
180 | $result['body'] .= $line . PHP_EOL; |
||
181 | break; |
||
182 | } |
||
183 | |||
184 | $index++; |
||
185 | } |
||
186 | |||
187 | $filtered = []; |
||
188 | $fixes_pattern = '/(closes|fixes)\s+#(\d+)/i'; |
||
189 | foreach ($results as $result) { |
||
190 | if ($result['type'] === 'skip') { |
||
191 | continue; |
||
192 | } |
||
193 | |||
194 | // check if the commit contains a breaking change |
||
195 | if (str_contains(strtolower($result['body']), 'breaking change:')) { |
||
196 | $result['type'] = 'break'; |
||
197 | } |
||
198 | |||
199 | // see if the commit fixed/closed issues |
||
200 | $matches = []; |
||
201 | preg_match_all($fixes_pattern, $result['body'], $matches); |
||
202 | if (!empty($matches) && !empty($matches[2])) { |
||
203 | $result['closes'] = array_map(function ($value) { |
||
204 | return (int) $value; |
||
205 | }, $matches[2]); |
||
206 | } |
||
207 | |||
208 | $filtered[] = $result; |
||
209 | } |
||
210 | |||
211 | return $filtered; |
||
212 | } |
||
213 | |||
214 | /** |
||
215 | * Get the contributors |
||
216 | * |
||
217 | * @param array $options options |
||
218 | * |
||
219 | * @return array |
||
220 | */ |
||
221 | protected function getGitContributors(array $options = []): array { |
||
222 | $defaults = [ |
||
223 | 'exclude' => [], |
||
224 | 'to' => 'HEAD', |
||
225 | ]; |
||
226 | $options = array_merge($defaults, $options); |
||
227 | |||
228 | $command = vsprintf('git shortlog -sne %s --no-merges %s', [ |
||
229 | $options['to'], |
||
230 | implode(' ', array_map(function ($value) { |
||
231 | if (str_contains(PHP_OS, 'WIN')) { |
||
232 | return "^^{$value}"; |
||
233 | } |
||
234 | |||
235 | return "^{$value}"; |
||
236 | }, $options['exclude'])), |
||
237 | ]); |
||
238 | |||
239 | $contributors = $this->executeCommand($command); |
||
1 ignored issue
–
show
Are you sure the assignment to
$contributors is correct as $this->executeCommand($command) targeting Elgg\Project\ChangelogWriter::executeCommand() seems to always return null.
This check looks for function or method calls that always return null and whose return value is assigned to a variable. class A
{
function getObject()
{
return null;
}
}
$a = new A();
$object = $a->getObject();
The method The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.
Loading history...
|
|||
240 | if (!isset($contributors)) { |
||
241 | return []; |
||
242 | } |
||
243 | |||
244 | $contributor_pattern = '/\s+([0-9]+)\s+(.*)\s<(.*)>/'; |
||
245 | $result = []; |
||
246 | foreach ($contributors as $contributor) { |
||
247 | $matches = []; |
||
248 | preg_match($contributor_pattern, $contributor, $matches); |
||
249 | if (empty($matches)) { |
||
250 | continue; |
||
251 | } |
||
252 | |||
253 | $result[] = [ |
||
254 | 'count' => (int) $matches[1], |
||
255 | 'name' => $matches[2], |
||
256 | 'email' => $matches[3], |
||
257 | ]; |
||
258 | } |
||
259 | |||
260 | // sort the contributors with most contributed first |
||
261 | usort($result, function ($a, $b) { |
||
262 | return $b['count'] - $a['count']; |
||
263 | }); |
||
264 | |||
265 | return $result; |
||
266 | } |
||
267 | |||
268 | /** |
||
269 | * Format the different commits into sections |
||
270 | * |
||
271 | * @param array $commits all the commits |
||
272 | * |
||
273 | * @return string |
||
274 | */ |
||
275 | protected function formatCommits(array $commits): string { |
||
276 | if (empty($commits)) { |
||
277 | return ''; |
||
278 | } |
||
279 | |||
280 | // group commits by type |
||
281 | $types = []; |
||
282 | foreach ($commits as $commit) { |
||
283 | $type = $commit['type']; |
||
284 | if (str_starts_with($type, 'feat')) { |
||
285 | $type = 'feature'; |
||
286 | } elseif (str_starts_with($type, 'fix')) { |
||
287 | $type = 'fix'; |
||
288 | } elseif (str_starts_with($type, 'perf')) { |
||
289 | $type = 'performance'; |
||
290 | } elseif (str_starts_with($type, 'doc')) { |
||
291 | $type = 'documentation'; |
||
292 | } elseif (str_starts_with($type, 'deprecate')) { |
||
293 | $type = 'deprecated'; |
||
294 | } elseif (str_starts_with($type, 'break')) { |
||
295 | $type = 'breaking'; |
||
296 | } elseif (str_starts_with($type, 'remove')) { |
||
297 | $type = 'removed'; |
||
298 | } else { |
||
299 | continue; |
||
300 | } |
||
301 | |||
302 | if (!isset($types[$type])) { |
||
303 | $types[$type] = []; |
||
304 | } |
||
305 | |||
306 | $component = $commit['component']; |
||
307 | if (!isset($types[$type][$component])) { |
||
308 | $types[$type][$component] = []; |
||
309 | } |
||
310 | |||
311 | $subject = $commit['subject']; |
||
312 | $commit_link = $this->makeCommitLink($commit); |
||
313 | $closes = ''; |
||
314 | if (!empty($commit['closes'])) { |
||
315 | $closes .= 'closes '; |
||
316 | foreach ($commit['closes'] as $issue_id) { |
||
317 | $closes .= $this->makeIssueLink($issue_id) . ', '; |
||
318 | } |
||
319 | } |
||
320 | |||
321 | $types[$type][$component][] = trim(vsprintf('%s %s %s', [ |
||
322 | $subject, |
||
323 | $commit_link, |
||
324 | $closes, |
||
325 | ]), ' ,'); |
||
326 | } |
||
327 | |||
328 | if (empty($types)) { |
||
329 | return ''; |
||
330 | } |
||
331 | |||
332 | // format the different types into sections |
||
333 | $sections = []; |
||
334 | foreach ($this->commit_types as $type => $label) { |
||
335 | if (!isset($types[$type])) { |
||
336 | continue; |
||
337 | } |
||
338 | |||
339 | $section = "#### {$label}" . PHP_EOL . PHP_EOL; |
||
340 | |||
341 | foreach ($types[$type] as $component => $commits) { |
||
342 | if (count($commits) === 1) { |
||
343 | $section .= "* **{$component}:** {$commits[0]}" . PHP_EOL; |
||
344 | } else { |
||
345 | $section .= "* **{$component}:**" . PHP_EOL; |
||
346 | |||
347 | foreach ($commits as $commit) { |
||
348 | $section .= ' * ' . $commit . PHP_EOL; |
||
349 | } |
||
350 | } |
||
351 | } |
||
352 | |||
353 | $sections[] = $section; |
||
354 | } |
||
355 | |||
356 | return trim(implode(PHP_EOL . PHP_EOL, $sections)); |
||
357 | } |
||
358 | |||
359 | /** |
||
360 | * Format the contributors into a section |
||
361 | * |
||
362 | * @param array $contributors contributors |
||
363 | * |
||
364 | * @return string |
||
365 | */ |
||
366 | protected function formatContributors(array $contributors): string { |
||
367 | if (empty($contributors)) { |
||
368 | return ''; |
||
369 | } |
||
370 | |||
371 | $section = '#### Contributors' . PHP_EOL . PHP_EOL; |
||
372 | |||
373 | foreach ($contributors as $contributor) { |
||
374 | $section .= "* {$contributor['name']} ({$contributor['count']})" . PHP_EOL; |
||
375 | } |
||
376 | |||
377 | return trim($section); |
||
378 | } |
||
379 | |||
380 | /** |
||
381 | * Format release header |
||
382 | * |
||
383 | * @return string |
||
384 | */ |
||
385 | protected function formatHeader(): string { |
||
386 | $version = $this->getOption('version', ''); |
||
387 | $parts = explode('.', $version); |
||
388 | $date = date('Y-m-d'); |
||
389 | |||
390 | $section = '<a name="' . $version . '"></a>' . PHP_EOL; |
||
391 | if ($parts[2] === '0') { |
||
392 | // major version |
||
393 | $section .= "## {$version} ({$date})"; |
||
394 | } else { |
||
395 | // patch version |
||
396 | $section .= "### {$version} ({$date})"; |
||
397 | } |
||
398 | |||
399 | return trim($section); |
||
400 | } |
||
401 | |||
402 | /** |
||
403 | * Get a link to a GitHub commit |
||
404 | * |
||
405 | * @param array $commit commit information |
||
406 | * |
||
407 | * @return string |
||
408 | */ |
||
409 | protected function makeCommitLink(array $commit): string { |
||
410 | if (empty($commit)) { |
||
411 | return ''; |
||
412 | } |
||
413 | |||
414 | return vsprintf('[%s](%s/commit/%s)', [ |
||
415 | $commit['short_hash'], |
||
416 | $this->getOption('repository'), |
||
417 | $commit['hash'], |
||
418 | ]); |
||
419 | } |
||
420 | |||
421 | /** |
||
422 | * Generate a link to a GitHub issue |
||
423 | * |
||
424 | * @param int $issue_id the issue ID |
||
425 | * |
||
426 | * @return string |
||
427 | */ |
||
428 | protected function makeIssueLink(int $issue_id): string { |
||
429 | if (empty($issue_id)) { |
||
430 | return ''; |
||
431 | } |
||
432 | |||
433 | return vsprintf('[#%s](%s/commit/%s)', [ |
||
434 | $issue_id, |
||
435 | $this->getOption('repository'), |
||
436 | $issue_id, |
||
437 | ]); |
||
438 | } |
||
439 | |||
440 | /** |
||
441 | * Write the release notes to the changelog |
||
442 | * |
||
443 | * @param string $release_notes release notes |
||
444 | * |
||
445 | * @return void |
||
446 | */ |
||
447 | protected function writeChangelog(string $release_notes): void { |
||
448 | $contents = file_get_contents($this->getOption('changelog')); |
||
449 | $first_anchor = strpos($contents, '<a name="'); |
||
450 | if ($first_anchor !== false) { |
||
451 | $contents = substr($contents, $first_anchor); |
||
452 | } |
||
453 | |||
454 | $contents = $release_notes . PHP_EOL . PHP_EOL . PHP_EOL . $contents; |
||
455 | |||
456 | file_put_contents($this->getOption('changelog'), $contents); |
||
457 | } |
||
458 | |||
459 | /** |
||
460 | * Execute a command |
||
461 | * |
||
462 | * @param string $command the command to execute |
||
463 | * |
||
464 | * @return null|array |
||
465 | */ |
||
466 | protected function executeCommand(string $command): ?array { |
||
467 | $output = []; |
||
468 | $result_code = null; |
||
469 | |||
470 | exec($command, $output, $result_code); |
||
471 | |||
472 | if ($result_code !== 0) { |
||
473 | return null; |
||
474 | } |
||
475 | |||
476 | return $output; |
||
477 | } |
||
478 | |||
479 | /** |
||
480 | * Get an option |
||
481 | * |
||
482 | * @param string $option name op the option |
||
483 | * @param mixed $default default value |
||
484 | * |
||
485 | * @return mixed |
||
486 | */ |
||
487 | protected function getOption(string $option, mixed $default = null): mixed { |
||
488 | return $this->options[$option] ?? $default; |
||
489 | } |
||
490 | } |
||
491 |
This check looks for function or method calls that always return null and whose return value is used.
The method
getObject()
can return nothing but null, so it makes no sense to use the return value.The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.