Elgg  Version master
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  $options['repository'] = rtrim($options['repository'], '/');
50 
51  $this->options = $options;
52  }
53 
59  public function __invoke(): void {
60  $tags = $this->getGitTags();
61 
62  $sections = [];
63 
64  $sections[] = $this->formatHeader();
65  $sections[] = $this->readNotes();
66 
67  $contributors = $this->getGitContributors([
68  'exclude' => $tags,
69  ]);
70  $sections[] = $this->formatContributors($contributors);
71 
72  $commits = $this->getGitCommits([
73  'exclude' => $tags,
74  ]);
75  $sections[] = $this->formatCommits($commits);
76 
77  $sections = array_filter($sections);
78  $output = trim(implode(PHP_EOL . PHP_EOL, $sections));
79 
80  $this->writeChangelog($output);
81  }
82 
88  protected function readNotes(): string {
89  $contents = file_get_contents($this->getOption('changelog'));
90  $first_anchor = strpos($contents, '<a name="');
91  if ($first_anchor === false) {
92  return trim($this->getOption('notes', ''));
93  }
94 
95  return trim($this->getOption('notes', '') . substr($contents, 0, $first_anchor));
96  }
97 
103  protected function getGitTags(): array {
104  return $this->executeCommand('git tag') ?: [];
105  }
106 
114  protected function getGitCommits(array $options): array {
115  $defaults = [
116  'grep' => '^[a-z]+(\(.*\))?:|BREAKING',
117  'format' => '%H%n%h%n%s%n%b%n==END==',
118  'exclude' => [],
119  'to' => 'HEAD',
120  ];
121  $options = array_merge($defaults, $options);
122 
123  $command = vsprintf('git log --grep="%s" -E --format=%s %s %s', [
124  $options['grep'],
125  $options['format'],
126  $options['to'],
127  implode(' ', array_map(function ($value) {
128  if (str_contains(PHP_OS, 'WIN')) {
129  return "^^{$value}";
130  }
131 
132  return "^{$value}";
133  }, $options['exclude'])),
134  ]);
135 
136  $commits = $this->executeCommand($command);
137  if (!isset($commits)) {
138  return [];
139  }
140 
141  $results = [];
142  $result = [
143  'body' => '',
144  ];
145  $index = 0;
146  $subject_pattern = '/^((Merge )|(Revert )|((\w*)\(([\w]+)\)\: ([^\n]*))$)/';
147  foreach ($commits as $line) {
148  if ($line === '==END==') {
149  $result['body'] = trim($result['body'] ?: '', PHP_EOL);
150 
151  $results[] = $result;
152  $index = 0;
153  $result = [
154  'body' => '',
155  ];
156  continue;
157  }
158 
159  switch ($index) {
160  case 0: // long hash
161  $result['hash'] = $line;
162  break;
163 
164  case 1: // short hash
165  $result['short_hash'] = $line;
166  break;
167 
168  case 2: // subject
169  $matches = [];
170  preg_match($subject_pattern, $line, $matches);
171 
172  $result['type'] = $matches[5] ?? 'skip';
173  $result['component'] = $matches[6] ?? '';
174  $result['subject'] = $matches[7] ?? '';
175  break;
176 
177  default: // the rest of the commit body
178  if (empty($line)) {
179  break;
180  }
181 
182  $result['body'] .= $line . PHP_EOL;
183  break;
184  }
185 
186  $index++;
187  }
188 
189  $filtered = [];
190  $fixes_pattern = '/(closes|fixes)\s+#(\d+)/i';
191  foreach ($results as $result) {
192  if ($result['type'] === 'skip') {
193  continue;
194  }
195 
196  // check if the commit contains a breaking change
197  if (str_contains(strtolower($result['body']), 'breaking change:')) {
198  $result['type'] = 'break';
199  }
200 
201  // see if the commit fixed/closed issues
202  $matches = [];
203  preg_match_all($fixes_pattern, $result['body'], $matches);
204  if (!empty($matches) && !empty($matches[2])) {
205  $result['closes'] = array_map(function ($value) {
206  return (int) $value;
207  }, $matches[2]);
208  }
209 
210  $filtered[] = $result;
211  }
212 
213  return $filtered;
214  }
215 
223  protected function getGitContributors(array $options = []): array {
224  $defaults = [
225  'exclude' => [],
226  'to' => 'HEAD',
227  ];
228  $options = array_merge($defaults, $options);
229 
230  $command = vsprintf('git shortlog -sne %s --no-merges %s', [
231  $options['to'],
232  implode(' ', array_map(function ($value) {
233  if (str_contains(PHP_OS, 'WIN')) {
234  return "^^{$value}";
235  }
236 
237  return "^{$value}";
238  }, $options['exclude'])),
239  ]);
240 
241  $contributors = $this->executeCommand($command);
242  if (!isset($contributors)) {
243  return [];
244  }
245 
246  $contributor_pattern = '/\s+([0-9]+)\s+(.*)\s<(.*)>/';
247  $result = [];
248  foreach ($contributors as $contributor) {
249  $matches = [];
250  preg_match($contributor_pattern, $contributor, $matches);
251  if (empty($matches)) {
252  continue;
253  }
254 
255  $result[] = [
256  'count' => (int) $matches[1],
257  'name' => $matches[2],
258  'email' => $matches[3],
259  ];
260  }
261 
262  // sort the contributors with most contributed first
263  usort($result, function ($a, $b) {
264  return $b['count'] - $a['count'];
265  });
266 
267  return $result;
268  }
269 
277  protected function formatCommits(array $commits): string {
278  if (empty($commits)) {
279  return '';
280  }
281 
282  // group commits by type
283  $types = [];
284  foreach ($commits as $commit) {
285  $type = $commit['type'];
286  if (str_starts_with($type, 'feat')) {
287  $type = 'feature';
288  } elseif (str_starts_with($type, 'fix')) {
289  $type = 'fix';
290  } elseif (str_starts_with($type, 'perf')) {
291  $type = 'performance';
292  } elseif (str_starts_with($type, 'doc')) {
293  $type = 'documentation';
294  } elseif (str_starts_with($type, 'deprecate')) {
295  $type = 'deprecated';
296  } elseif (str_starts_with($type, 'break')) {
297  $type = 'breaking';
298  } elseif (str_starts_with($type, 'remove')) {
299  $type = 'removed';
300  } else {
301  continue;
302  }
303 
304  if (!isset($types[$type])) {
305  $types[$type] = [];
306  }
307 
308  $component = $commit['component'];
309  if (!isset($types[$type][$component])) {
310  $types[$type][$component] = [];
311  }
312 
313  $subject = $commit['subject'];
314  $commit_link = $this->makeCommitLink($commit);
315  $closes = '';
316  if (!empty($commit['closes'])) {
317  $closes .= 'closes ';
318  foreach ($commit['closes'] as $issue_id) {
319  $closes .= $this->makeIssueLink($issue_id) . ', ';
320  }
321  }
322 
323  $types[$type][$component][] = trim(vsprintf('%s %s %s', [
324  $subject,
325  $commit_link,
326  $closes,
327  ]), ' ,');
328  }
329 
330  if (empty($types)) {
331  return '';
332  }
333 
334  // format the different types into sections
335  $sections = [];
336  foreach ($this->commit_types as $type => $label) {
337  if (!isset($types[$type])) {
338  continue;
339  }
340 
341  $section = "#### {$label}" . PHP_EOL . PHP_EOL;
342 
343  foreach ($types[$type] as $component => $commits) {
344  if (count($commits) === 1) {
345  $section .= "* **{$component}:** {$commits[0]}" . PHP_EOL;
346  } else {
347  $section .= "* **{$component}:**" . PHP_EOL;
348 
349  foreach ($commits as $commit) {
350  $section .= ' * ' . $commit . PHP_EOL;
351  }
352  }
353  }
354 
355  $sections[] = $section;
356  }
357 
358  return trim(implode(PHP_EOL . PHP_EOL, $sections));
359  }
360 
368  protected function formatContributors(array $contributors): string {
369  if (empty($contributors)) {
370  return '';
371  }
372 
373  $section = '#### Contributors' . PHP_EOL . PHP_EOL;
374 
375  foreach ($contributors as $contributor) {
376  $section .= "* {$contributor['name']} ({$contributor['count']})" . PHP_EOL;
377  }
378 
379  return trim($section);
380  }
381 
387  protected function formatHeader(): string {
388  $version = $this->getOption('version', '');
389  $parts = explode('.', $version);
390  $date = date('Y-m-d');
391 
392  $section = '<a name="' . $version . '"></a>' . PHP_EOL;
393  if ($parts[2] === '0') {
394  // major version
395  $section .= "## {$version} ({$date})";
396  } else {
397  // patch version
398  $section .= "### {$version} ({$date})";
399  }
400 
401  return trim($section);
402  }
403 
411  protected function makeCommitLink(array $commit): string {
412  if (empty($commit)) {
413  return '';
414  }
415 
416  return vsprintf('[%s](%s/commit/%s)', [
417  $commit['short_hash'],
418  $this->getOption('repository'),
419  $commit['hash'],
420  ]);
421  }
422 
430  protected function makeIssueLink(int $issue_id): string {
431  if (empty($issue_id)) {
432  return '';
433  }
434 
435  return vsprintf('[#%s](%s/issues/%s)', [
436  $issue_id,
437  $this->getOption('repository'),
438  $issue_id,
439  ]);
440  }
441 
449  protected function writeChangelog(string $release_notes): void {
450  $contents = file_get_contents($this->getOption('changelog'));
451  $first_anchor = strpos($contents, '<a name="');
452  if ($first_anchor !== false) {
453  $contents = substr($contents, $first_anchor);
454  }
455 
456  $contents = $release_notes . PHP_EOL . PHP_EOL . PHP_EOL . $contents;
457 
458  file_put_contents($this->getOption('changelog'), $contents);
459  }
460 
468  protected function executeCommand(string $command): ?array {
469  $output = [];
470  $result_code = null;
471 
472  exec($command, $output, $result_code);
473 
474  if ($result_code !== 0) {
475  return null;
476  }
477 
478  return $output;
479  }
480 
489  protected function getOption(string $option, mixed $default = null): mixed {
490  return $this->options[$option] ?? $default;
491  }
492 }
$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:21
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.