Completed
Push — add/changelog-tooling ( 257a85 )
by
unknown
149:15 queued 138:48
created

AddCommand   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 308
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 2

Importance

Changes 0
Metric Value
dl 0
loc 308
rs 8.5599
c 0
b 0
f 0
wmc 48
lcom 1
cbo 2

4 Methods

Rating   Name   Duplication   Size   Complexity  
A configure() 0 30 2
B validateFilename() 0 30 9
A getDefaultFilename() 0 16 4
F execute() 0 168 33

How to fix   Complexity   

Complex Class

Complex classes like AddCommand 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 AddCommand, and based on these observations, apply Extract Interface, too.

1
<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
2
/**
3
 * "Add" command for the changelogger tool CLI.
4
 *
5
 * @package automattic/jetpack-changelogger
6
 */
7
8
// phpcs:disable WordPress.NamingConventions.ValidVariableName
9
10
namespace Automattic\Jetpack\Changelogger\Console;
11
12
use Automattic\Jetpack\Changelogger\Config;
13
use Automattic\Jetpack\Changelogger\Utils;
14
use Symfony\Component\Console\Command\Command;
15
use Symfony\Component\Console\Exception\MissingInputException;
16
use Symfony\Component\Console\Input\InputInterface;
17
use Symfony\Component\Console\Input\InputOption;
18
use Symfony\Component\Console\Output\OutputInterface;
19
use Symfony\Component\Console\Question\ChoiceQuestion;
20
use Symfony\Component\Console\Question\Question;
21
use function Wikimedia\quietCall;
22
23
/**
24
 * "Add" command for the changelogger tool CLI.
25
 */
26
class AddCommand extends Command {
27
28
	/**
29
	 * Bad characters for filenames.
30
	 *
31
	 * @var array
32
	 */
33
	private static $badChars = array(
34
		'<'  => 'angle brackets',
35
		'>'  => 'angle brackets',
36
		':'  => 'colons',
37
		'"'  => 'double quotes',
38
		'/'  => 'slashes',
39
		'\\' => 'backslashes',
40
		'|'  => 'pipes',
41
		'?'  => 'question marks',
42
		'*'  => 'asterisks',
43
	);
44
45
	/**
46
	 * Significance values and descriptions.
47
	 *
48
	 * @var array
49
	 */
50
	private static $significances = array(
51
		'patch' => 'Backwards-compatible bug fixes.',
52
		'minor' => 'Added (or deprecated) functionality in a backwards-compatible manner.',
53
		'major' => 'Broke backwards compatibility in some way.',
54
	);
55
56
	/**
57
	 * The default command name
58
	 *
59
	 * @var string|null
60
	 */
61
	protected static $defaultName = 'add';
62
63
	/**
64
	 * Configures the command.
65
	 */
66
	protected function configure() {
67
		$joiner = function ( $arr ) {
68
			return implode(
69
				"\n",
70
				array_map(
71
					function ( $k, $v ) {
72
						return " - $k: $v";
73
					},
74
					array_keys( $arr ),
75
					$arr
76
				)
77
			);
78
		};
79
80
		$this->setDescription( 'Adds a changelog entry file' )
81
			->addOption( 'filename', 'f', InputOption::VALUE_REQUIRED, 'Name for the changelog file. If not provided, a default will be determined from the current timestamp or git branch name.' )
82
			->addOption( 'significance', 's', InputOption::VALUE_REQUIRED, "Significance of the change, in the style of semantic versioning. One of the following:\n" . $joiner( self::$significances ) )
83
			->addOption( 'type', 't', InputOption::VALUE_REQUIRED, Config::types() ? "Type of change. One of the following:\n" . $joiner( Config::types() ) : 'Normally this would be used to indicate the type of change, but this project does not use types.' )
84
			->addOption( 'comment', 'c', InputOption::VALUE_REQUIRED, 'Optional comment to include in the file.' )
85
			->addOption( 'entry', 'e', InputOption::VALUE_REQUIRED, 'Changelog entry. May be empty if the significance is "patch".' )
86
			->setHelp(
87
				<<<EOF
88
The <info>add</info> command adds a new changelog file to the changelog directory.
89
90
By default this is an interactive process: the user will be queried for the necessary
91
information, with command line arguments supplying default values. Use <info>--no-interaction</info>
92
to create an entry non-interactively.
93
EOF
94
			);
95
	}
96
97
	/**
98
	 * Validate a filename.
99
	 *
100
	 * @param string $filename Filename.
101
	 * @return string $filename
102
	 * @throws \RuntimeException On error.
103
	 */
104
	public function validateFilename( $filename ) {
105
		if ( '' === $filename ) {
106
			throw new \RuntimeException( 'Filename may not be empty.' );
107
		}
108
109
		if ( '.' === $filename[0] ) {
110
			throw new \RuntimeException( 'Filename may not begin with a dot.' );
111
		}
112
113
		$bad = array();
114
		foreach ( self::$badChars as $c => $name ) {
115
			if ( strpos( $filename, $c ) !== false ) {
116
				$bad[ $name ] = true;
117
			}
118
		}
119
		if ( $bad ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $bad 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...
120
			$bad = array_keys( $bad );
121
			if ( count( $bad ) > 1 ) {
122
				$bad[ count( $bad ) - 1 ] = 'or ' . $bad[ count( $bad ) - 1 ];
123
			}
124
			throw new \RuntimeException( 'Filename may not contain ' . implode( count( $bad ) > 2 ? ', ' : ' ', $bad ) . '.' );
125
		}
126
127
		$path = Config::base() . "/changelog/$filename";
128
		if ( file_exists( $path ) ) {
129
			throw new \RuntimeException( "File \"$path\" already exists. If you want to replace it, delete it manually." );
130
		}
131
132
		return $filename;
133
	}
134
135
	/**
136
	 * Get the default filename.
137
	 *
138
	 * @param OutputInterface $output OutputInterface.
139
	 * @return string
140
	 */
141
	protected function getDefaultFilename( OutputInterface $output ) {
142
		try {
143
			$process = Utils::runCommand( array( 'git', 'rev-parse', '--abbrev-ref', 'HEAD' ), $output, $this->getHelper( 'debug_formatter' ) );
144
			if ( $process->isSuccessful() ) {
145
				$ret = trim( $process->getOutput() );
146
				if ( ! in_array( $ret, array( '', 'master', 'main', 'trunk' ), true ) ) {
147
					return strtr( $ret, array_fill_keys( array_keys( self::$badChars ), '-' ) );
148
				}
149
			}
150
		} catch ( \Throwable $t ) { // @codeCoverageIgnore
0 ignored issues
show
Bug introduced by
The class Throwable does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
151
			$output->writeln( "Command failed: {$t->getMessage()}", OutputInterface::VERBOSITY_DEBUG ); // @codeCoverageIgnore
152
		}
153
154
		$date = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
155
		return $date->format( 'Y-m-d-H-i-s-u' );
156
	}
157
158
	/**
159
	 * Executes the command.
160
	 *
161
	 * @param InputInterface  $input InputInterface.
162
	 * @param OutputInterface $output OutputInterface.
163
	 * @return int 0 if everything went fine, or an exit code.
164
	 */
165
	protected function execute( InputInterface $input, OutputInterface $output ) {
166
		try {
167
			$dir = Config::base() . '/changelog';
168
			if ( ! is_dir( $dir ) ) {
169
				Utils::error_clear_last();
170
				if ( ! quietCall( 'mkdir', $dir, 0775, true ) ) {
171
					$err = error_get_last();
172
					$output->writeln( "<error>Could not create directory $dir: {$err['message']}</>" );
173
					return 1;
174
				}
175
			}
176
177
			$isInteractive = $input->isInteractive();
178
179
			// Determine the changelog entry filename.
180
			$filename = $input->getOption( 'filename' );
181
			if ( null === $filename ) {
182
				$filename = $this->getDefaultFilename( $output );
183
			}
184
			if ( $isInteractive ) {
185
				$question = new Question( "Name your changelog file <info>[default: $filename]</> > ", $filename );
186
				$question->setValidator( array( $this, 'validateFilename' ) );
187
				$filename = $this->getHelper( 'question' )->ask( $input, $output, $question );
188
				if ( null === $filename ) { // non-interactive.
189
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
190
					return 1;
191
				}
192
			} else {
193
				if ( null === $input->getOption( 'filename' ) ) {
194
					$output->writeln( "Using default filename \"$filename\".", OutputInterface::VERBOSITY_VERBOSE );
195
				}
196
				try {
197
					$this->validateFilename( $filename );
198
				} catch ( \RuntimeException $ex ) {
199
					$output->writeln( "<error>{$ex->getMessage()}</>" );
200
					return 1;
201
				}
202
			}
203
204
			$contents = '';
205
206
			// Determine the change significance and add to the file contents.
207
			$significance = $input->getOption( 'significance' );
208
			if ( null !== $significance ) {
209
				$significance = strtolower( $significance );
210
			}
211
			if ( $isInteractive ) {
212
				$question     = new ChoiceQuestion( 'Significance of the change, in the style of semantic versioning.', self::$significances, $significance );
213
				$significance = $this->getHelper( 'question' )->ask( $input, $output, $question );
214
				if ( null === $significance ) { // non-interactive.
215
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
216
					return 1;
217
				}
218
			} else {
219
				if ( null === $significance ) {
220
					$output->writeln( '<error>Significance must be specified in non-interactive mode.</>' );
221
					return 1;
222
				}
223
				if ( ! isset( self::$significances[ $significance ] ) ) {
224
					$output->writeln( "<error>Significance value \"$significance\" is not valid.</>" );
225
					return 1;
226
				}
227
			}
228
			$contents .= "Significance: $significance\n";
229
230
			// Determine the change type and add to the file contents, if applicable.
231
			$types = Config::types();
232
			if ( $types ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $types 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...
233
				$type = $input->getOption( 'type' );
234
				if ( null !== $type ) {
235
					$type = strtolower( $type );
236
				}
237
				if ( $isInteractive ) {
238
					$question = new ChoiceQuestion( 'Type of change.', $types, $type );
239
					$type     = $this->getHelper( 'question' )->ask( $input, $output, $question );
240
					if ( null === $type ) { // non-interactive.
241
						$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
242
						return 1;
243
					}
244
				} else {
245
					if ( null === $type ) {
246
						$output->writeln( '<error>Type must be specified in non-interactive mode.</>' );
247
						return 1;
248
					}
249
					if ( ! isset( $types[ $type ] ) ) {
250
						$output->writeln( "<error>Type \"$type\" is not valid.</>" );
251
						return 1;
252
					}
253
				}
254
				$contents .= "Type: $type\n";
255
			}
256
257
			// Determine the change comment, and add to the file contents if applicable.
258
			$comment = (string) $input->getOption( 'comment' );
259
			if ( $isInteractive ) {
260
				$question = new Question( "Comment about the change. Optional, feel free to leave empty.\n > ", $comment );
261
				$comment  = $this->getHelper( 'question' )->ask( $input, $output, $question );
262
				if ( null === $comment ) {
263
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
264
					return 1; // @codeCoverageIgnore
265
				}
266
			}
267
			$comment = trim( preg_replace( '/\s+/', ' ', $comment ) );
268
			if ( '' !== $comment ) {
269
				$contents .= "Comment: $comment\n";
270
			}
271
272
			// Determine the changelog entry and add to the file contents.
273
			$entry = $input->getOption( 'entry' );
274
			if ( $isInteractive ) {
275
				if ( 'patch' === $significance ) {
276
					$question = new Question( "Changelog entry. May be left empty if this change is particularly insignificant.\n > ", (string) $entry );
277
				} else {
278
					$question = new Question( "Changelog entry. May not be empty.\n > ", $entry );
279
					$question->setValidator(
280
						function ( $v ) {
281
							if ( trim( $v ) === '' ) {
282
								throw new \RuntimeException( 'An empty changelog entry is only allowed when the significance is "patch".' );
283
							}
284
							return $v;
285
						}
286
					);
287
				}
288
				$entry = $this->getHelper( 'question' )->ask( $input, $output, $question );
289
				if ( null === $entry ) {
290
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
291
					return 1;
292
				}
293
			} else {
294
				if ( null === $entry ) {
295
					$output->writeln( '<error>Entry must be specified in non-interactive mode.</>' );
296
					return 1;
297
				}
298
				if ( 'patch' !== $significance && '' === $entry ) {
299
					$output->writeln( '<error>An empty changelog entry is only allowed when the significance is "patch".</>' );
300
					return 1;
301
				}
302
			}
303
			$contents .= "\n$entry";
304
305
			// Ok! Write the file.
306
			// Use fopen/fwrite/fclose instead of file_put_contents because the latter doesn't support 'x'.
307
			$output->writeln(
308
				"<info>Creating changelog entry $dir/$filename:\n" . preg_replace( '/^/m', '  ', $contents ) . '</>',
309
				OutputInterface::VERBOSITY_DEBUG
310
			);
311
			$contents .= "\n";
312
			Utils::error_clear_last();
313
			$fp = quietCall( 'fopen', "$dir/$filename", 'x' );
314
			if ( ! $fp ||
315
				quietCall( 'fwrite', $fp, $contents ) !== strlen( $contents ) ||
316
				! quietCall( 'fclose', $fp )
317
			) {
318
				// @codeCoverageIgnoreStart
319
				$err = error_get_last();
320
				$output->writeln( "<error>Failed to write file \"$dir/$filename\": {$err['message']}.</>" );
321
				quietCall( 'fclose', $fp );
322
				quietCall( 'unlink', "$dir/$filename" );
323
				return 1;
324
				// @codeCoverageIgnoreEnd
325
			}
326
327
			return 0;
328
		} catch ( MissingInputException $ex ) { // @codeCoverageIgnore
0 ignored issues
show
Bug introduced by
The class Symfony\Component\Consol...n\MissingInputException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
329
			$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
330
			return 1; // @codeCoverageIgnore
331
		}
332
	}
333
}
334