Passed
Push — master ( e14136...5f573b )
by Alexander
02:29
created

RevisionPrinter::_getRelativeLogPath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 4
c 1
b 0
f 0
dl 0
loc 9
ccs 0
cts 5
cp 0
rs 10
cc 2
nc 2
nop 3
crap 6
1
<?php
2
/**
3
 * This file is part of the SVN-Buddy library.
4
 * For the full copyright and license information, please view
5
 * the LICENSE file that was distributed with this source code.
6
 *
7
 * @copyright Alexander Obuhovich <[email protected]>
8
 * @link      https://github.com/console-helpers/svn-buddy
9
 */
10
11
namespace ConsoleHelpers\SVNBuddy\Repository\RevisionLog;
12
13
14
use ConsoleHelpers\SVNBuddy\Helper\DateHelper;
15
use ConsoleHelpers\SVNBuddy\Helper\OutputHelper;
16
use Symfony\Component\Console\Helper\Table;
17
use Symfony\Component\Console\Helper\TableCell;
18
use Symfony\Component\Console\Helper\TableSeparator;
19
use Symfony\Component\Console\Output\OutputInterface;
20
21
class RevisionPrinter
22
{
23
24
	const COLUMN_FULL_MESSAGE = 1;
25
26
	const COLUMN_DETAILS = 2;
27
28
	const COLUMN_SUMMARY = 3;
29
30
	const COLUMN_REFS = 4;
31
32
	const COLUMN_MERGE_ORACLE = 5;
33
34
	const COLUMN_MERGE_STATUS = 6;
35
36
	/**
37
	 * Date helper.
38
	 *
39
	 * @var DateHelper
40
	 */
41
	private $_dateHelper;
42
43
	/**
44
	 * Output helper.
45
	 *
46
	 * @var OutputHelper
47
	 */
48
	private $_outputHelper;
49
50
	/**
51
	 * Accent style.
52
	 *
53
	 * @var string
54
	 */
55
	private $_accentStyle;
56
57
	/**
58
	 * Columns.
59
	 *
60
	 * @var array
61
	 */
62
	private $_columns = array();
63
64
	/**
65
	 * Aggregate by bug.
66
	 *
67
	 * @var boolean
68
	 */
69
	private $_aggregateByBug = false;
70
71
	/**
72
	 * Merge conflict regexps.
73
	 *
74
	 * @var array
75
	 */
76
	private $_mergeConflictRegExps = array();
77
78
	/**
79
	 * Log message limit.
80
	 *
81
	 * @var integer
82
	 */
83
	private $_logMessageLimit = 68;
84
85
	/**
86
	 * Current revision (e.g. in a working copy).
87
	 *
88
	 * @var integer|null
89
	 */
90
	private $_currentRevision;
91
92
	/**
93
	 * Creates instance of revision printer.
94
	 *
95
	 * @param DateHelper   $date_helper   Date helper.
96
	 * @param OutputHelper $output_helper Output helper.
97
	 * @param boolean      $is_dark_theme Is dark theme.
98
	 */
99 1
	public function __construct(DateHelper $date_helper, OutputHelper $output_helper, $is_dark_theme)
100
	{
101 1
		$this->_dateHelper = $date_helper;
102 1
		$this->_outputHelper = $output_helper;
103 1
		$this->_accentStyle = $is_dark_theme ? 'fg=white;options=bold' : 'fg=cyan';
104
105 1
		$this->_resetState();
106
	}
107
108
	/**
109
	 * Resets state.
110
	 *
111
	 * @return void
112
	 */
113 1
	private function _resetState()
114
	{
115 1
		$this->_columns = array();
116 1
		$this->_mergeConflictRegExps = array();
117 1
		$this->_logMessageLimit = 68;
118 1
		$this->_aggregateByBug = false;
119
	}
120
121
	/**
122
	 * Adds column to the output.
123
	 *
124
	 * @param integer $column Column.
125
	 *
126
	 * @return self
127
	 */
128
	public function withColumn($column)
129
	{
130
		$this->_columns[] = $column;
131
132
		return $this;
133
	}
134
135
	/**
136
	 * Sets aggregate by bug.
137
	 *
138
	 * @param boolean $aggregate_by_bug Aggregate by bug.
139
	 *
140
	 * @return void
141
	 */
142
	public function setAggregateByBug($aggregate_by_bug)
143
	{
144
		$this->_aggregateByBug = $aggregate_by_bug;
145
	}
146
147
	/**
148
	 * Sets merge conflict regexps.
149
	 *
150
	 * @param array $merge_conflict_regexps Merge conflict regexps.
151
	 *
152
	 * @return void
153
	 */
154
	public function setMergeConflictRegExps(array $merge_conflict_regexps)
155
	{
156
		$this->_mergeConflictRegExps = $merge_conflict_regexps;
157
	}
158
159
	/**
160
	 * Sets log message limit.
161
	 *
162
	 * @param integer $log_message_limit Log message limit.
163
	 *
164
	 * @return void
165
	 */
166
	public function setLogMessageLimit($log_message_limit)
167
	{
168
		$this->_logMessageLimit = $log_message_limit;
169
	}
170
171
	/**
172
	 * Sets current revision.
173
	 *
174
	 * @param integer $revision Revision.
175
	 *
176
	 * @return void
177
	 */
178
	public function setCurrentRevision($revision)
179
	{
180
		$this->_currentRevision = $revision;
181
	}
182
183
	/**
184
	 * Prints revisions.
185
	 *
186
	 * @param RevisionLog     $revision_log Revision log.
187
	 * @param array           $revisions    Revisions.
188
	 * @param OutputInterface $output       Output.
189
	 *
190
	 * @return void
191
	 */
192
	public function printRevisions(RevisionLog $revision_log, array $revisions, OutputInterface $output)
193
	{
194
		$table = new Table($output);
195
		$headers = array('Revision', 'Author', 'Date', 'Bug-ID', 'Log Message');
196
197
		$with_full_message = in_array(self::COLUMN_FULL_MESSAGE, $this->_columns);
198
		$with_details = in_array(self::COLUMN_DETAILS, $this->_columns);
199
200
		// Add "Summary" header.
201
		$with_summary = in_array(self::COLUMN_SUMMARY, $this->_columns);
202
203
		if ( $with_summary ) {
204
			$headers[] = 'Summary';
205
		}
206
207
		// Add "Refs" header.
208
		$with_refs = in_array(self::COLUMN_REFS, $this->_columns);
209
210
		if ( $with_refs ) {
211
			$headers[] = 'Refs';
212
		}
213
214
		$with_merge_oracle = in_array(self::COLUMN_MERGE_ORACLE, $this->_columns);
215
216
		// Add "M.O." header.
217
		if ( $with_merge_oracle ) {
218
			$headers[] = 'M.O.';
219
		}
220
221
		// Add "Merged Via" header.
222
		$with_merge_status = in_array(self::COLUMN_MERGE_STATUS, $this->_columns);
223
224
		if ( $with_merge_status ) {
225
			$headers[] = 'Merged Via';
226
		}
227
228
		$table->setHeaders($headers);
229
230
		$prev_bugs = null;
231
		$last_bug_color = 'yellow';
232
233
		if ( $this->_aggregateByBug ) {
234
			$aggregated_revisions = $this->aggregateRevisionsByBug($revisions, $revision_log);
235
			$revisions = array_keys($aggregated_revisions);
236
		}
237
238
		$last_revision = end($revisions);
239
240
		$project_path = $revision_log->getProjectPath();
241
242
		$bugs_per_row = $with_details ? 1 : 3;
243
244
		$revisions_data = $revision_log->getRevisionsData('summary', $revisions);
245
		$revisions_paths = $revision_log->getRevisionsData('paths', $revisions);
246
		$revisions_bugs = $revision_log->getRevisionsData('bugs', $revisions);
247
		$revisions_refs = $revision_log->getRevisionsData('refs', $revisions);
248
249
		if ( $with_merge_status ) {
250
			$revisions_merged_via = $revision_log->getRevisionsData('merges', $revisions);
251
			$revisions_merged_via_refs = $revision_log->getRevisionsData(
252
				'refs',
253
				call_user_func_array('array_merge', $revisions_merged_via)
254
			);
255
		}
256
257
		$revision_url_mask = $revision_log->getRevisionURLBuilder()->getMask();
258
259
		// Use mask only, when it contains an URL.
260
		if ( strpos($revision_url_mask, '://') === false ) {
261
			$revision_url_mask = '';
262
		}
263
264
		$first_revision = reset($revisions);
265
266
		foreach ( $revisions as $revision ) {
267
			$revision_data = $revisions_data[$revision];
268
269
			$new_bugs = $revisions_bugs[$revision];
270
271
			if ( isset($prev_bugs) && $new_bugs !== $prev_bugs ) {
272
				$last_bug_color = $last_bug_color === 'yellow' ? 'magenta' : 'yellow';
273
			}
274
275
			if ( $revision === $this->_currentRevision ) {
276
				$bugs_cell = $this->_outputHelper->formatArray($new_bugs, $bugs_per_row);
277
			}
278
			else {
279
				$bugs_cell = $this->_outputHelper->formatArray($new_bugs, $bugs_per_row, $last_bug_color);
280
			}
281
282
			$row = array(
283
				$this->_aggregateByBug ? $aggregated_revisions[$revision] . ' cmts' : $revision,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $aggregated_revisions does not seem to be defined for all execution paths leading up to this point.
Loading history...
284
				$revision_data['author'],
285
				$this->_dateHelper->getAgoTime($revision_data['date']),
286
				$bugs_cell,
287
				$this->_generateLogMessageColumn($with_full_message || $with_details, $revision_data),
288
			);
289
290
			$revision_paths = $revisions_paths[$revision];
291
292
			// Add "Summary" column.
293
			if ( $with_summary ) {
294
				$row[] = $this->_generateSummaryColumn($revision_paths);
295
			}
296
297
			// Add "Refs" column.
298
			if ( $with_refs ) {
299
				$row[] = $this->_outputHelper->formatArray(
300
					$revisions_refs[$revision],
301
					1
302
				);
303
			}
304
305
			// Add "M.O." column.
306
			if ( $with_merge_oracle ) {
307
				$merge_conflict_prediction = $this->_getMergeConflictPrediction($revision_paths);
308
				$row[] = $merge_conflict_prediction ? '<error>' . count($merge_conflict_prediction) . '</error>' : '';
309
			}
310
			else {
311
				$merge_conflict_prediction = array();
312
			}
313
314
			// Add "Merged Via" column.
315
			if ( $with_merge_status ) {
316
				$row[] = $this->_generateMergedViaColumn($revisions_merged_via[$revision], $revisions_merged_via_refs);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $revisions_merged_via_refs does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $revisions_merged_via does not seem to be defined for all execution paths leading up to this point.
Loading history...
317
			}
318
319
			if ( $revision === $this->_currentRevision ) {
320
				foreach ( $row as $index => $cell ) {
321
					$row[$index] = $this->applyStyle($cell, $this->_accentStyle);
322
				}
323
			}
324
325
			if ( $with_full_message && $revision !== $first_revision ) {
326
				$table->addRow(new TableSeparator());
327
			}
328
329
			$table->addRow($row);
330
331
			if ( $with_details ) {
332
				$details = $this->_generateDetailsRowContent(
333
					$revision,
334
					$revisions_refs,
335
					$revision_paths,
336
					$merge_conflict_prediction,
337
					$project_path,
338
					$revision_url_mask
339
				);
340
341
				$table->addRow(new TableSeparator());
342
				$table->addRow(array(
343
					new TableCell($details, array('colspan' => count($headers))),
344
				));
345
346
				if ( $revision != $last_revision ) {
347
					$table->addRow(new TableSeparator());
348
				}
349
			}
350
351
			$prev_bugs = $new_bugs;
352
		}
353
354
		$table->render();
355
356
		$this->_resetState();
357
	}
358
359
	/**
360
	 * Aggregates revisions by bugs.
361
	 *
362
	 * @param array       $revisions    Revisions.
363
	 * @param RevisionLog $revision_log Revision Log.
364
	 *
365
	 * @return array
366
	 */
367
	protected function aggregateRevisionsByBug(array $revisions, RevisionLog $revision_log)
368
	{
369
		$bugs_revisions = array(
370
			'unknown' => array(),
371
		);
372
373
		$revisions_bugs = $revision_log->getRevisionsData('bugs', $revisions);
374
375
		foreach ( \array_reverse($revisions) as $revision ) {
376
			$revision_bugs = $revisions_bugs[$revision];
377
378
			if ( !$revision_bugs ) {
379
				$bugs_revisions['unknown'][] = $revision;
380
				continue;
381
			}
382
383
			foreach ( $revision_bugs as $revision_bug ) {
384
				if ( !isset($bugs_revisions[$revision_bug]) ) {
385
					$bugs_revisions[$revision_bug] = array();
386
				}
387
388
				$bugs_revisions[$revision_bug][] = $revision;
389
			}
390
		}
391
392
		if ( !$bugs_revisions['unknown'] ) {
393
			unset($bugs_revisions['unknown']);
394
		}
395
396
		$bugs_revisions = \array_reverse($bugs_revisions, true);
397
398
		$ret = array();
399
400
		foreach ( $bugs_revisions as $bug => $bug_revisions ) {
401
			$ret[reset($bug_revisions)] = count($bug_revisions);
402
		}
403
404
		return $ret;
405
	}
406
407
	/**
408
	 * Applies a style to the text.
409
	 *
410
	 * @param string $text  Text.
411
	 * @param string $style Style.
412
	 *
413
	 * @return string
414
	 */
415
	protected function applyStyle($text, $style)
416
	{
417
		if ( strpos($text, PHP_EOL) === false ) {
418
			return '<' . $style . '>' . $text . '</>';
419
		}
420
421
		$lines = explode(PHP_EOL, $text);
422
423
		return '<' . $style . '>' . implode('</>' . PHP_EOL . '<' . $style . '>', $lines) . '</>';
424
	}
425
426
	/**
427
	 * Returns log message.
428
	 *
429
	 * @param boolean $with_full_message Show commit message without truncation.
430
	 * @param array   $revision_data     Revision data.
431
	 *
432
	 * @return string
433
	 */
434
	private function _generateLogMessageColumn($with_full_message, array $revision_data)
435
	{
436
		$commit_message = trim($revision_data['msg']);
437
438
		if ( $with_full_message ) {
439
			// When details requested don't transform commit message except for word wrapping.
440
			// FIXME: Not UTF-8 safe solution.
441
			$log_message = wordwrap($commit_message, $this->_logMessageLimit);
442
443
			return $log_message;
444
		}
445
		else {
446
			// When details not requested only operate on first line of commit message.
447
			list($log_message,) = explode(PHP_EOL, $commit_message);
448
			$log_message = preg_replace('/(^|\s+)\[fixes:.*?\]/s', "$1\xE2\x9C\x94", $log_message);
449
450
			if ( strpos($commit_message, PHP_EOL) !== false
451
				|| mb_strlen($log_message) > $this->_logMessageLimit
452
			) {
453
				$log_message = mb_substr($log_message, 0, $this->_logMessageLimit - 3) . '...';
454
455
				return $log_message;
456
			}
457
458
			return $log_message;
459
		}
460
	}
461
462
	/**
463
	 * Generates change summary for a revision.
464
	 *
465
	 * @param array $revision_paths Revision paths.
466
	 *
467
	 * @return string
468
	 */
469
	private function _generateSummaryColumn(array $revision_paths)
470
	{
471
		$summary = array('added' => 0, 'changed' => 0, 'removed' => 0);
472
473
		foreach ( $revision_paths as $path_data ) {
474
			$path_action = $path_data['action'];
475
476
			if ( $path_action === 'A' ) {
477
				$summary['added']++;
478
			}
479
			elseif ( $path_action === 'D' ) {
480
				$summary['removed']++;
481
			}
482
			else {
483
				$summary['changed']++;
484
			}
485
		}
486
487
		if ( $summary['added'] ) {
488
			$summary['added'] = '<fg=green>+' . $summary['added'] . '</>';
489
		}
490
491
		if ( $summary['removed'] ) {
492
			$summary['removed'] = '<fg=red>-' . $summary['removed'] . '</>';
493
		}
494
495
		return implode(' ', array_filter($summary));
496
	}
497
498
	/**
499
	 * Returns merge conflict path predictions.
500
	 *
501
	 * @param array $revision_paths Revision paths.
502
	 *
503
	 * @return array
504
	 */
505
	private function _getMergeConflictPrediction(array $revision_paths)
506
	{
507
		if ( !$this->_mergeConflictRegExps ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->_mergeConflictRegExps of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
508
			return array();
509
		}
510
511
		$conflict_paths = array();
512
513
		foreach ( $revision_paths as $revision_path ) {
514
			foreach ( $this->_mergeConflictRegExps as $merge_conflict_regexp ) {
515
				if ( preg_match($merge_conflict_regexp, $revision_path['path']) ) {
516
					$conflict_paths[] = $revision_path['path'];
517
				}
518
			}
519
		}
520
521
		return $conflict_paths;
522
	}
523
524
	/**
525
	 * Generates content for "Merged Via" cell content.
526
	 *
527
	 * @param array $merged_via                Merged Via.
528
	 * @param array $revisions_merged_via_refs Merged Via Refs.
529
	 *
530
	 * @return string
531
	 */
532
	private function _generateMergedViaColumn(array $merged_via, array $revisions_merged_via_refs)
533
	{
534
		if ( !$merged_via ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $merged_via of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
535
			return '';
536
		}
537
538
		$merged_via_enhanced = array();
539
540
		foreach ( $merged_via as $merged_via_revision ) {
541
			$merged_via_revision_refs = $revisions_merged_via_refs[$merged_via_revision];
542
543
			if ( $merged_via_revision_refs ) {
544
				$merged_via_enhanced[] = $merged_via_revision . ' (' . implode(',', $merged_via_revision_refs) . ')';
545
			}
546
			else {
547
				$merged_via_enhanced[] = $merged_via_revision;
548
			}
549
		}
550
551
		return $this->_outputHelper->formatArray($merged_via_enhanced, 1);
552
	}
553
554
	/**
555
	 * Generates details row content.
556
	 *
557
	 * @param integer $revision                  Revision.
558
	 * @param array   $revisions_refs            Refs.
559
	 * @param array   $revision_paths            Revision paths.
560
	 * @param array   $merge_conflict_prediction Merge conflict prediction.
561
	 * @param string  $project_path              Project path.
562
	 * @param string  $revision_url_mask         Revision URL mask.
563
	 *
564
	 * @return string
565
	 */
566
	private function _generateDetailsRowContent(
567
		$revision,
568
		array $revisions_refs,
569
		array $revision_paths,
570
		array $merge_conflict_prediction,
571
		$project_path,
572
		$revision_url_mask
573
	) {
574
		$details = '';
575
576
		if ( $revision_url_mask ) {
577
			$details .= '<' . $this->_accentStyle . '>Revision URL:</>' . PHP_EOL;
578
			$details .= str_replace('{revision}', $revision, $revision_url_mask) . PHP_EOL . PHP_EOL;
579
		}
580
581
		$details .= '<' . $this->_accentStyle . '>Changed Paths:</>';
582
		$path_cut_off_regexp = $this->getPathCutOffRegExp($project_path, $revisions_refs[$revision]);
583
584
		foreach ( $revision_paths as $path_data ) {
585
			$path_action = $path_data['action'];
586
			$relative_path = $this->_getRelativeLogPath($path_data, 'path', $path_cut_off_regexp);
587
588
			$details .= PHP_EOL . ' * ';
589
590
			if ( $path_action === 'A' ) {
591
				$color_format = 'fg=green';
592
			}
593
			elseif ( $path_action === 'D' ) {
594
				$color_format = 'fg=red';
595
			}
596
			else {
597
				$color_format = in_array($path_data['path'], $merge_conflict_prediction) ? 'error' : '';
598
			}
599
600
			$to_colorize = array($path_action . '    ' . $relative_path);
601
602
			if ( isset($path_data['copyfrom-path']) ) {
603
				// TODO: When copy happened from different ref/project, then relative path = absolute path.
604
				$copy_from_rev = $path_data['copyfrom-rev'];
605
				$copy_from_path = $this->_getRelativeLogPath($path_data, 'copyfrom-path', $path_cut_off_regexp);
606
				$to_colorize[] = '        (from ' . $copy_from_path . ':' . $copy_from_rev . ')';
607
			}
608
609
			if ( $color_format ) {
610
				$details .= '<' . $color_format . '>';
611
				$details .= implode('</>' . PHP_EOL . '<' . $color_format . '>', $to_colorize);
612
				$details .= '</>';
613
			}
614
			else {
615
				$details .= implode(PHP_EOL, $to_colorize);
616
			}
617
		}
618
619
		return $details;
620
	}
621
622
	/**
623
	 * Returns path cut off regexp.
624
	 *
625
	 * @param string $project_path Project path.
626
	 * @param array  $refs         Refs.
627
	 *
628
	 * @return string
629
	 */
630
	protected function getPathCutOffRegExp($project_path, array $refs)
0 ignored issues
show
Unused Code introduced by
The parameter $refs is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

630
	protected function getPathCutOffRegExp($project_path, /** @scrutinizer ignore-unused */ array $refs)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
631
	{
632
		$ret = array();
633
634
		// Remove ref from path only for single-ref revision.
635
		/*if ( count($refs) === 1 ) {
636
			$ret[] = $project_path . reset($refs) . '/';
637
		}*/
638
639
		// Always remove project path.
640
		$ret[] = $project_path;
641
642
		return '#^(' . implode('|', array_map('preg_quote', $ret)) . ')#';
643
	}
644
645
	/**
646
	 * Returns relative path to "svn log" returned path.
647
	 *
648
	 * @param array  $path_data           Path data.
649
	 * @param string $path_key            Path key.
650
	 * @param string $path_cut_off_regexp Path cut off regexp.
651
	 *
652
	 * @return string
653
	 */
654
	private function _getRelativeLogPath(array $path_data, $path_key, $path_cut_off_regexp)
655
	{
656
		$ret = preg_replace($path_cut_off_regexp, '', $path_data[$path_key], 1);
657
658
		if ( $ret === '' ) {
659
			$ret = '.';
660
		}
661
662
		return $ret;
663
	}
664
665
}
666