Elgg  Version 5.1
ChangelogWriter.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Elgg\Project;
4 
6 
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 
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 
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 
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 
101  protected function getGitTags(): array {
102  return $this->executeCommand('git tag') ?: [];
103  }
104 
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);
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 
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);
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 
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 
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 
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 
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 
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 
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 
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 
487  protected function getOption(string $option, mixed $default = null): mixed {
488  return $this->options[$option] ?? $default;
489  }
490 }
$default
Definition: checkbox.php:30
Helper class to write the changelog during release.
if($view &&elgg_view_exists($view)) $label
Definition: field.php:26
static elgg()
Get the Elgg codebase path with "/".
Definition: Paths.php:44
Exception thrown if an argument is not of the expected type.
readNotes()
Read anything in the changelog before the first &#39;&#39; and consider this release notes.
__invoke()
Write the changelog for the current release.
$defaults
Generic entity header upload helper.
Definition: header.php:6
formatCommits(array $commits)
Format the different commits into sections.
getGitTags()
Get the current git tags.
$version
c Accompany it with the information you received as to the offer to distribute corresponding source complete source code means all the source code for all modules it plus any associated interface definition plus the scripts used to control compilation and installation of the executable as a special the source code distributed need not include anything that is normally and so on of the operating system on which the executable unless that component itself accompanies the executable If distribution of executable or object code is made by offering access to copy from a designated then offering equivalent access to copy the source code from the same place counts as distribution of the source even though third parties are not compelled to copy the source along with the object code You may not or distribute the Program except as expressly provided under this License Any attempt otherwise to sublicense or distribute the Program is void
Definition: LICENSE.txt:215
getGitContributors(array $options=[])
Get the contributors.
$type
Definition: delete.php:22
makeCommitLink(array $commit)
Get a link to a GitHub commit.
writeChangelog(string $release_notes)
Write the release notes to the changelog.
if($item instanceof\ElggEntity) elseif($item instanceof\ElggRiverItem) elseif($item instanceof\ElggRelationship) elseif(is_callable([$item, 'getType']))
Definition: item.php:48
$value
Definition: generic.php:51
formatContributors(array $contributors)
Format the contributors into a section.
getGitCommits(array $options)
Get all the commits.
formatHeader()
Format release header.
executeCommand(string $command)
Execute a command.
$results
Definition: content.php:22
getOption(string $option, mixed $default=null)
Get an option.
$sections
Definition: admin.php:16
$index
Definition: gallery.php:40
$tags
Output object tags.
Definition: tags.php:9
$output
Definition: download.php:9
if(!empty($title)&&!empty($icon_name)) if(!empty($title)) if(!empty($menu)) if(!empty($header)) if(!empty($body)) $contents
Definition: message.php:73
$subject
Definition: useradd.php:54
array __construct(array $options=[])
Constructor.
makeIssueLink(int $issue_id)
Generate a link to a GitHub issue.