ChangelogWriter   F
last analyzed

Complexity

Total Complexity 63

Size/Duplication

Total Lines 476
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 228
dl 0
loc 476
ccs 0
cts 240
cp 0
rs 3.36
c 1
b 0
f 0
wmc 63

14 Methods

Rating   Name   Duplication   Size   Complexity  
A makeIssueLink() 0 9 2
A writeChangelog() 0 10 2
A __construct() 0 18 4
A __invoke() 0 22 1
A readNotes() 0 8 2
A makeCommitLink() 0 9 2
C getGitCommits() 0 100 15
A formatHeader() 0 15 2
A formatContributors() 0 12 3
A getGitContributors() 0 45 5
A executeCommand() 0 11 2
A getGitTags() 0 2 2
F formatCommits() 0 82 20
A getOption() 0 2 1

How to fix   Complexity   

Complex Class

Complex classes like ChangelogWriter often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ChangelogWriter, and based on these observations, apply Extract Interface, too.

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') ?: [];
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);
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);
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