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 }
$type
Definition: delete.php:21
$subject
Definition: useradd.php:54
return[ 'admin/delete_admin_notices'=>['access'=> 'admin'], 'admin/menu/save'=>['access'=> 'admin'], 'admin/plugins/activate'=>['access'=> 'admin'], 'admin/plugins/activate_all'=>['access'=> 'admin'], 'admin/plugins/deactivate'=>['access'=> 'admin'], 'admin/plugins/deactivate_all'=>['access'=> 'admin'], 'admin/plugins/set_priority'=>['access'=> 'admin'], 'admin/security/security_txt'=>['access'=> 'admin'], 'admin/security/settings'=>['access'=> 'admin'], 'admin/security/regenerate_site_secret'=>['access'=> 'admin'], 'admin/site/cache/invalidate'=>['access'=> 'admin'], 'admin/site/flush_cache'=>['access'=> 'admin'], 'admin/site/icons'=>['access'=> 'admin'], 'admin/site/set_maintenance_mode'=>['access'=> 'admin'], 'admin/site/set_robots'=>['access'=> 'admin'], 'admin/site/theme'=>['access'=> 'admin'], 'admin/site/unlock_upgrade'=>['access'=> 'admin'], 'admin/site/settings'=>['access'=> 'admin'], 'admin/upgrade'=>['access'=> 'admin'], 'admin/upgrade/reset'=>['access'=> 'admin'], 'admin/user/ban'=>['access'=> 'admin'], 'admin/user/bulk/ban'=>['access'=> 'admin'], 'admin/user/bulk/delete'=>['access'=> 'admin'], 'admin/user/bulk/unban'=>['access'=> 'admin'], 'admin/user/bulk/validate'=>['access'=> 'admin'], 'admin/user/change_email'=>['access'=> 'admin'], 'admin/user/delete'=>['access'=> 'admin'], 'admin/user/login_as'=>['access'=> 'admin'], 'admin/user/logout_as'=>[], 'admin/user/makeadmin'=>['access'=> 'admin'], 'admin/user/resetpassword'=>['access'=> 'admin'], 'admin/user/removeadmin'=>['access'=> 'admin'], 'admin/user/unban'=>['access'=> 'admin'], 'admin/user/validate'=>['access'=> 'admin'], 'annotation/delete'=>[], 'avatar/upload'=>[], 'comment/save'=>[], 'diagnostics/download'=>['access'=> 'admin'], 'entity/chooserestoredestination'=>[], 'entity/delete'=>[], 'entity/mute'=>[], 'entity/restore'=>[], 'entity/subscribe'=>[], 'entity/trash'=>[], 'entity/unmute'=>[], 'entity/unsubscribe'=>[], 'login'=>['access'=> 'logged_out'], 'logout'=>[], 'notifications/mute'=>['access'=> 'public'], 'plugins/settings/remove'=>['access'=> 'admin'], 'plugins/settings/save'=>['access'=> 'admin'], 'plugins/usersettings/save'=>[], 'register'=>['access'=> 'logged_out', 'middleware'=>[\Elgg\Router\Middleware\RegistrationAllowedGatekeeper::class,],], 'river/delete'=>[], 'settings/notifications'=>[], 'settings/notifications/subscriptions'=>[], 'user/changepassword'=>['access'=> 'public'], 'user/requestnewpassword'=>['access'=> 'public'], 'useradd'=>['access'=> 'admin'], 'usersettings/save'=>[], 'widgets/add'=>[], 'widgets/delete'=>[], 'widgets/move'=>[], 'widgets/save'=>[],]
Definition: actions.php:73
Exception thrown if an argument is not of the expected type.
Helper class to write the changelog during release.
formatContributors(array $contributors)
Format the contributors into a section.
getGitContributors(array $options=[])
Get the contributors.
makeIssueLink(int $issue_id)
Generate a link to a GitHub issue.
getGitCommits(array $options)
Get all the commits.
formatHeader()
Format release header.
getGitTags()
Get the current git tags.
formatCommits(array $commits)
Format the different commits into sections.
makeCommitLink(array $commit)
Get a link to a GitHub commit.
writeChangelog(string $release_notes)
Write the release notes to the changelog.
getOption(string $option, mixed $default=null)
Get an option.
__construct(array $options=[])
Constructor.
executeCommand(string $command)
Execute a command.
readNotes()
Read anything in the changelog before the first '' and consider this release notes.
__invoke()
Write the changelog for the current release.
static elgg()
Get the Elgg codebase path with "/".
Definition: Paths.php:44
if($who_can_change_language==='nobody') elseif($who_can_change_language==='admin_only' &&!elgg_is_admin_logged_in()) $options
Definition: language.php:20
$version
$index
Definition: gallery.php:40
if($item instanceof \ElggEntity) elseif($item instanceof \ElggRiverItem) elseif($item instanceof \ElggRelationship) elseif(is_callable([ $item, 'getType']))
Definition: item.php:48
$output
Definition: download.php:9
if($view &&elgg_view_exists($view)) $label
Definition: field.php:26
$defaults
Generic entity header upload helper.
Definition: header.php:6
$value
Definition: generic.php:51
$default
Definition: checkbox.php:30
if(!empty($title) &&!empty($icon_name)) if(!empty($title)) if(!empty($menu)) if(!empty($header)) if(!empty($body)) $contents
Definition: message.php:73
$section
Definition: section.php:30
$tags
Output object tags.
Definition: tags.php:9
if(parse_url(elgg_get_site_url(), PHP_URL_PATH) !=='/') if(file_exists(elgg_get_root_path() . 'robots.txt'))
Set robots.txt.
Definition: robots.php:10
$sections
Definition: admin.php:16
$results
Definition: content.php:22