Completed
Push — add/testing-info ( be1095...03b7e9 )
by
unknown
09:20
created

AddCommand::execute()   F

Complexity

Conditions 38
Paths > 20000

Size

Total Lines 179

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 38
nc 76473
nop 2
dl 0
loc 179
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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;
11
12
use Symfony\Component\Console\Command\Command;
13
use Symfony\Component\Console\Exception\MissingInputException;
14
use Symfony\Component\Console\Input\InputInterface;
15
use Symfony\Component\Console\Input\InputOption;
16
use Symfony\Component\Console\Output\OutputInterface;
17
use Symfony\Component\Console\Question\ChoiceQuestion;
18
use Symfony\Component\Console\Question\Question;
19
use function Wikimedia\quietCall;
20
21
/**
22
 * "Add" command for the changelogger tool CLI.
23
 */
24
class AddCommand extends Command {
25
26
	/**
27
	 * Bad characters for filenames.
28
	 *
29
	 * @var array
30
	 */
31
	private static $badChars = array(
32
		'<'  => 'angle brackets',
33
		'>'  => 'angle brackets',
34
		':'  => 'colons',
35
		'"'  => 'double quotes',
36
		'/'  => 'slashes',
37
		'\\' => 'backslashes',
38
		'|'  => 'pipes',
39
		'?'  => 'question marks',
40
		'*'  => 'asterisks',
41
	);
42
43
	/**
44
	 * Significance values and descriptions.
45
	 *
46
	 * @var array
47
	 */
48
	private static $significances = array(
49
		'patch' => 'Backwards-compatible bug fixes.',
50
		'minor' => 'Added (or deprecated) functionality in a backwards-compatible manner.',
51
		'major' => 'Broke backwards compatibility in some way.',
52
	);
53
54
	/**
55
	 * The default command name
56
	 *
57
	 * @var string|null
58
	 */
59
	protected static $defaultName = 'add';
60
61
	/**
62
	 * Configures the command.
63
	 */
64
	protected function configure() {
65
		$joiner = function ( $arr ) {
66
			return implode(
67
				"\n",
68
				array_map(
69
					function ( $k, $v ) {
70
						return " - $k: $v";
71
					},
72
					array_keys( $arr ),
73
					$arr
74
				)
75
			);
76
		};
77
78
		$this->setDescription( 'Adds a change file' )
79
			->addOption( 'filename', 'f', InputOption::VALUE_REQUIRED, 'Name for the change file. If not provided, a default will be determined from the current timestamp or git branch name.' )
80
			->addOption( 'filename-auto-suffix', null, InputOption::VALUE_NONE, 'If the specified file already exists in non-interactive mode, add a numeric suffix so the new entry can be created.' )
81
			->addOption( 'significance', 's', InputOption::VALUE_REQUIRED, "Significance of the change, in the style of semantic versioning. One of the following:\n" . $joiner( self::$significances ) )
82
			->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. Do not use.' )
83
			->addOption( 'comment', 'c', InputOption::VALUE_REQUIRED, 'Optional comment to include in the file.' )
84
			->addOption( 'entry', 'e', InputOption::VALUE_REQUIRED, 'Changelog entry. May be empty if the significance is "patch".' )
85
			->setHelp(
86
				<<<EOF
87
The <info>add</info> command adds a new change file to the changelog directory.
88
89
By default this is an interactive process: the user will be queried for the necessary
90
information, with command line arguments supplying default values. Use <info>--no-interaction</info>
91
to create an entry non-interactively.
92
EOF
93
			);
94
	}
95
96
	/**
97
	 * Validate a filename.
98
	 *
99
	 * @param string $filename Filename.
100
	 * @return string $filename
101
	 * @throws \RuntimeException On error.
102
	 */
103
	public function validateFilename( $filename ) {
104
		if ( '' === $filename ) {
105
			throw new \RuntimeException( 'Filename may not be empty.' );
106
		}
107
108
		if ( '.' === $filename[0] ) {
109
			throw new \RuntimeException( 'Filename may not begin with a dot.' );
110
		}
111
112
		$bad = array();
113
		foreach ( self::$badChars as $c => $name ) {
114
			if ( strpos( $filename, $c ) !== false ) {
115
				$bad[ $name ] = true;
116
			}
117
		}
118
		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...
119
			$bad = array_keys( $bad );
120 View Code Duplication
			if ( count( $bad ) > 1 ) {
121
				$bad[ count( $bad ) - 1 ] = 'or ' . $bad[ count( $bad ) - 1 ];
122
			}
123
			throw new \RuntimeException( 'Filename may not contain ' . implode( count( $bad ) > 2 ? ', ' : ' ', $bad ) . '.' );
124
		}
125
126
		$path = Config::changesDir() . "/$filename";
127
		if ( file_exists( $path ) ) {
128
			throw new \RuntimeException( "File \"$path\" already exists. If you want to replace it, delete it manually." );
129
		}
130
131
		return $filename;
132
	}
133
134
	/**
135
	 * Get the default filename.
136
	 *
137
	 * @param OutputInterface $output OutputInterface.
138
	 * @return string
139
	 */
140
	protected function getDefaultFilename( OutputInterface $output ) {
141
		try {
142
			$process = Utils::runCommand( array( 'git', 'rev-parse', '--abbrev-ref', 'HEAD' ), $output, $this->getHelper( 'debug_formatter' ) );
143
			if ( $process->isSuccessful() ) {
144
				$ret = trim( $process->getOutput() );
145
				if ( ! in_array( $ret, array( '', 'master', 'main', 'trunk' ), true ) ) {
146
					return strtr( $ret, array_fill_keys( array_keys( self::$badChars ), '-' ) );
147
				}
148
			}
149
		} catch ( \Exception $ex ) { // @codeCoverageIgnore
150
			$output->writeln( "Command failed: {$ex->getMessage()}", OutputInterface::VERBOSITY_DEBUG ); // @codeCoverageIgnore
151
		}
152
153
		$date = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
154
		return $date->format( 'Y-m-d-H-i-s-u' );
155
	}
156
157
	/**
158
	 * Executes the command.
159
	 *
160
	 * @param InputInterface  $input InputInterface.
161
	 * @param OutputInterface $output OutputInterface.
162
	 * @return int 0 if everything went fine, or an exit code.
163
	 */
164
	protected function execute( InputInterface $input, OutputInterface $output ) {
165
		try {
166
			$dir = Config::changesDir();
167
			if ( ! is_dir( $dir ) ) {
168
				Utils::error_clear_last();
169
				if ( ! quietCall( 'mkdir', $dir, 0775, true ) ) {
170
					$err = error_get_last();
171
					$output->writeln( "<error>Could not create directory $dir: {$err['message']}</>" );
172
					return 1;
173
				}
174
			}
175
176
			$isInteractive = $input->isInteractive();
177
178
			// Determine the changelog entry filename.
179
			$filename = $input->getOption( 'filename' );
180
			if ( null === $filename ) {
181
				$filename = $this->getDefaultFilename( $output );
182
			}
183
			if ( $isInteractive ) {
184
				$question = new Question( "Name your change file <info>[default: $filename]</> > ", $filename );
185
				$question->setValidator( array( $this, 'validateFilename' ) );
186
				$filename = $this->getHelper( 'question' )->ask( $input, $output, $question );
187
				if ( null === $filename ) { // non-interactive.
188
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
189
					return 1;
190
				}
191
			} else {
192
				if ( null === $input->getOption( 'filename' ) ) {
193
					$output->writeln( "Using default filename \"$filename\".", OutputInterface::VERBOSITY_VERBOSE );
194
				}
195
				if ( file_exists( "$dir/$filename" ) && $input->getOption( 'filename-auto-suffix' ) ) {
196
					$i = 2;
197
					while ( file_exists( "$dir/$filename#$i" ) ) {
198
						$i++;
199
					}
200
					$output->writeln( "File \"$filename\" already exists. Creating \"$filename#$i\" instead.", OutputInterface::VERBOSITY_VERBOSE );
201
					$filename = "$filename#$i";
202
				}
203
				try {
204
					$this->validateFilename( $filename );
205
				} catch ( \RuntimeException $ex ) {
206
					$output->writeln( "<error>{$ex->getMessage()}</>" );
207
					return 1;
208
				}
209
			}
210
211
			$contents = '';
212
213
			// Determine the change significance and add to the file contents.
214
			$significance = $input->getOption( 'significance' );
215
			if ( null !== $significance ) {
216
				$significance = strtolower( $significance );
217
			}
218
			if ( $isInteractive ) {
219
				$question     = new ChoiceQuestion( 'Significance of the change, in the style of semantic versioning.', self::$significances, $significance );
220
				$significance = $this->getHelper( 'question' )->ask( $input, $output, $question );
221
				if ( null === $significance ) { // non-interactive.
222
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
223
					return 1;
224
				}
225
			} else {
226
				if ( null === $significance ) {
227
					$output->writeln( '<error>Significance must be specified in non-interactive mode.</>' );
228
					return 1;
229
				}
230
				if ( ! isset( self::$significances[ $significance ] ) ) {
231
					$output->writeln( "<error>Significance value \"$significance\" is not valid.</>" );
232
					return 1;
233
				}
234
			}
235
			$contents .= "Significance: $significance\n";
236
237
			// Determine the change type and add to the file contents, if applicable.
238
			$types = Config::types();
239
			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...
240
				$type = $input->getOption( 'type' );
241
				if ( null !== $type ) {
242
					$type = strtolower( $type );
243
				}
244
				if ( $isInteractive ) {
245
					$question = new ChoiceQuestion( 'Type of change.', $types, $type );
246
					$type     = $this->getHelper( 'question' )->ask( $input, $output, $question );
247
					if ( null === $type ) { // non-interactive.
248
						$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
249
						return 1;
250
					}
251
				} else {
252
					if ( null === $type ) {
253
						$output->writeln( '<error>Type must be specified in non-interactive mode.</>' );
254
						return 1;
255
					}
256
					if ( ! isset( $types[ $type ] ) ) {
257
						$output->writeln( "<error>Type \"$type\" is not valid.</>" );
258
						return 1;
259
					}
260
				}
261
				$contents .= "Type: $type\n";
262
			} elseif ( null !== $input->getOption( 'type' ) ) {
263
				$output->writeln( '<warning>This project does not use types. Do not specify --type.</>' );
264
			}
265
266
			// Determine the changelog entry and add to the file contents.
267
			$entry = $input->getOption( 'entry' );
268
			if ( $isInteractive ) {
269
				if ( 'patch' === $significance ) {
270
					$question = new Question( "Changelog entry. May be left empty if this change is particularly insignificant.\n > ", (string) $entry );
271
				} else {
272
					$question = new Question( "Changelog entry. May not be empty.\n > ", $entry );
273
					$question->setValidator(
274
						function ( $v ) {
275
							if ( trim( $v ) === '' ) {
276
								throw new \RuntimeException( 'An empty changelog entry is only allowed when the significance is "patch".' );
277
							}
278
							return $v;
279
						}
280
					);
281
				}
282
				$entry = $this->getHelper( 'question' )->ask( $input, $output, $question );
283
				if ( null === $entry ) {
284
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
285
					return 1;
286
				}
287
			} else {
288
				if ( null === $entry ) {
289
					$output->writeln( '<error>Entry must be specified in non-interactive mode.</>' );
290
					return 1;
291
				}
292
				if ( 'patch' !== $significance && '' === $entry ) {
293
					$output->writeln( '<error>An empty changelog entry is only allowed when the significance is "patch".</>' );
294
					return 1;
295
				}
296
			}
297
298
			// Ask if a change comment is desired, if they left the change entry itself empty.
299
			$comment = (string) $input->getOption( 'comment' );
300
			if ( $isInteractive && '' === $entry ) {
301
				$question = new Question( "You omitted the changelog entry, which is fine. But please comment as to why no entry is needed.\n > ", $comment );
302
				$comment  = $this->getHelper( 'question' )->ask( $input, $output, $question );
303
				if ( null === $comment ) {
304
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
305
					return 1; // @codeCoverageIgnore
306
				}
307
			}
308
			$comment = trim( preg_replace( '/\s+/', ' ', $comment ) );
309
			if ( '' !== $comment ) {
310
				$contents .= "Comment: $comment\n";
311
			}
312
313
			$contents .= "\n$entry";
314
315
			// Ok! Write the file.
316
			// Use fopen/fwrite/fclose instead of file_put_contents because the latter doesn't support 'x'.
317
			$output->writeln(
318
				"<info>Creating changelog entry $dir/$filename:\n" . preg_replace( '/^/m', '  ', $contents ) . '</>',
319
				OutputInterface::VERBOSITY_DEBUG
320
			);
321
			$contents .= "\n";
322
			Utils::error_clear_last();
323
			$fp = quietCall( 'fopen', "$dir/$filename", 'x' );
324
			if ( ! $fp ||
325
				quietCall( 'fwrite', $fp, $contents ) !== strlen( $contents ) ||
326
				! quietCall( 'fclose', $fp )
327
			) {
328
				// @codeCoverageIgnoreStart
329
				$err = error_get_last();
330
				$output->writeln( "<error>Failed to write file \"$dir/$filename\": {$err['message']}.</>" );
331
				quietCall( 'fclose', $fp );
332
				quietCall( 'unlink', "$dir/$filename" );
333
				return 1;
334
				// @codeCoverageIgnoreEnd
335
			}
336
337
			return 0;
338
		} 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...
339
			$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
340
			return 1; // @codeCoverageIgnore
341
		}
342
	}
343
}
344