Completed
Push — update/use-identity-crisis-pac... ( d14b6d...6e141b )
by
unknown
128:58 queued 119:18
created

AddCommand::validateFilename()   B

Complexity

Conditions 9
Paths 14

Size

Total Lines 30

Duplication

Lines 3
Ratio 10 %

Importance

Changes 0
Metric Value
cc 9
nc 14
nop 1
dl 3
loc 30
rs 8.0555
c 0
b 0
f 0
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( 'significance', 's', InputOption::VALUE_REQUIRED, "Significance of the change, in the style of semantic versioning. One of the following:\n" . $joiner( self::$significances ) )
81
			->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.' )
82
			->addOption( 'comment', 'c', InputOption::VALUE_REQUIRED, 'Optional comment to include in the file.' )
83
			->addOption( 'entry', 'e', InputOption::VALUE_REQUIRED, 'Changelog entry. May be empty if the significance is "patch".' )
84
			->setHelp(
85
				<<<EOF
86
The <info>add</info> command adds a new change file to the changelog directory.
87
88
By default this is an interactive process: the user will be queried for the necessary
89
information, with command line arguments supplying default values. Use <info>--no-interaction</info>
90
to create an entry non-interactively.
91
EOF
92
			);
93
	}
94
95
	/**
96
	 * Validate a filename.
97
	 *
98
	 * @param string $filename Filename.
99
	 * @return string $filename
100
	 * @throws \RuntimeException On error.
101
	 */
102
	public function validateFilename( $filename ) {
103
		if ( '' === $filename ) {
104
			throw new \RuntimeException( 'Filename may not be empty.' );
105
		}
106
107
		if ( '.' === $filename[0] ) {
108
			throw new \RuntimeException( 'Filename may not begin with a dot.' );
109
		}
110
111
		$bad = array();
112
		foreach ( self::$badChars as $c => $name ) {
113
			if ( strpos( $filename, $c ) !== false ) {
114
				$bad[ $name ] = true;
115
			}
116
		}
117
		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...
118
			$bad = array_keys( $bad );
119 View Code Duplication
			if ( count( $bad ) > 1 ) {
120
				$bad[ count( $bad ) - 1 ] = 'or ' . $bad[ count( $bad ) - 1 ];
121
			}
122
			throw new \RuntimeException( 'Filename may not contain ' . implode( count( $bad ) > 2 ? ', ' : ' ', $bad ) . '.' );
123
		}
124
125
		$path = Config::changesDir() . "/$filename";
126
		if ( file_exists( $path ) ) {
127
			throw new \RuntimeException( "File \"$path\" already exists. If you want to replace it, delete it manually." );
128
		}
129
130
		return $filename;
131
	}
132
133
	/**
134
	 * Get the default filename.
135
	 *
136
	 * @param OutputInterface $output OutputInterface.
137
	 * @return string
138
	 */
139
	protected function getDefaultFilename( OutputInterface $output ) {
140
		try {
141
			$process = Utils::runCommand( array( 'git', 'rev-parse', '--abbrev-ref', 'HEAD' ), $output, $this->getHelper( 'debug_formatter' ) );
142
			if ( $process->isSuccessful() ) {
143
				$ret = trim( $process->getOutput() );
144
				if ( ! in_array( $ret, array( '', 'master', 'main', 'trunk' ), true ) ) {
145
					return strtr( $ret, array_fill_keys( array_keys( self::$badChars ), '-' ) );
146
				}
147
			}
148
		} catch ( \Exception $ex ) { // @codeCoverageIgnore
149
			$output->writeln( "Command failed: {$ex->getMessage()}", OutputInterface::VERBOSITY_DEBUG ); // @codeCoverageIgnore
150
		}
151
152
		$date = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
153
		return $date->format( 'Y-m-d-H-i-s-u' );
154
	}
155
156
	/**
157
	 * Executes the command.
158
	 *
159
	 * @param InputInterface  $input InputInterface.
160
	 * @param OutputInterface $output OutputInterface.
161
	 * @return int 0 if everything went fine, or an exit code.
162
	 */
163
	protected function execute( InputInterface $input, OutputInterface $output ) {
164
		try {
165
			$dir = Config::changesDir();
166
			if ( ! is_dir( $dir ) ) {
167
				Utils::error_clear_last();
168
				if ( ! quietCall( 'mkdir', $dir, 0775, true ) ) {
169
					$err = error_get_last();
170
					$output->writeln( "<error>Could not create directory $dir: {$err['message']}</>" );
171
					return 1;
172
				}
173
			}
174
175
			$isInteractive = $input->isInteractive();
176
177
			// Determine the changelog entry filename.
178
			$filename = $input->getOption( 'filename' );
179
			if ( null === $filename ) {
180
				$filename = $this->getDefaultFilename( $output );
181
			}
182
			if ( $isInteractive ) {
183
				$question = new Question( "Name your change file <info>[default: $filename]</> > ", $filename );
184
				$question->setValidator( array( $this, 'validateFilename' ) );
185
				$filename = $this->getHelper( 'question' )->ask( $input, $output, $question );
186
				if ( null === $filename ) { // non-interactive.
187
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
188
					return 1;
189
				}
190
			} else {
191
				if ( null === $input->getOption( 'filename' ) ) {
192
					$output->writeln( "Using default filename \"$filename\".", OutputInterface::VERBOSITY_VERBOSE );
193
				}
194
				try {
195
					$this->validateFilename( $filename );
196
				} catch ( \RuntimeException $ex ) {
197
					$output->writeln( "<error>{$ex->getMessage()}</>" );
198
					return 1;
199
				}
200
			}
201
202
			$contents = '';
203
204
			// Determine the change significance and add to the file contents.
205
			$significance = $input->getOption( 'significance' );
206
			if ( null !== $significance ) {
207
				$significance = strtolower( $significance );
208
			}
209
			if ( $isInteractive ) {
210
				$question     = new ChoiceQuestion( 'Significance of the change, in the style of semantic versioning.', self::$significances, $significance );
211
				$significance = $this->getHelper( 'question' )->ask( $input, $output, $question );
212
				if ( null === $significance ) { // non-interactive.
213
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
214
					return 1;
215
				}
216
			} else {
217
				if ( null === $significance ) {
218
					$output->writeln( '<error>Significance must be specified in non-interactive mode.</>' );
219
					return 1;
220
				}
221
				if ( ! isset( self::$significances[ $significance ] ) ) {
222
					$output->writeln( "<error>Significance value \"$significance\" is not valid.</>" );
223
					return 1;
224
				}
225
			}
226
			$contents .= "Significance: $significance\n";
227
228
			// Determine the change type and add to the file contents, if applicable.
229
			$types = Config::types();
230
			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...
231
				$type = $input->getOption( 'type' );
232
				if ( null !== $type ) {
233
					$type = strtolower( $type );
234
				}
235
				if ( $isInteractive ) {
236
					$question = new ChoiceQuestion( 'Type of change.', $types, $type );
237
					$type     = $this->getHelper( 'question' )->ask( $input, $output, $question );
238
					if ( null === $type ) { // non-interactive.
239
						$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
240
						return 1;
241
					}
242
				} else {
243
					if ( null === $type ) {
244
						$output->writeln( '<error>Type must be specified in non-interactive mode.</>' );
245
						return 1;
246
					}
247
					if ( ! isset( $types[ $type ] ) ) {
248
						$output->writeln( "<error>Type \"$type\" is not valid.</>" );
249
						return 1;
250
					}
251
				}
252
				$contents .= "Type: $type\n";
253
			} elseif ( null !== $input->getOption( 'type' ) ) {
254
				$output->writeln( '<warning>This project does not use types. Do not specify --type.</>' );
255
			}
256
257
			// Determine the changelog entry and add to the file contents.
258
			$entry = $input->getOption( 'entry' );
259
			if ( $isInteractive ) {
260
				if ( 'patch' === $significance ) {
261
					$question = new Question( "Changelog entry. May be left empty if this change is particularly insignificant.\n > ", (string) $entry );
262
				} else {
263
					$question = new Question( "Changelog entry. May not be empty.\n > ", $entry );
264
					$question->setValidator(
265
						function ( $v ) {
266
							if ( trim( $v ) === '' ) {
267
								throw new \RuntimeException( 'An empty changelog entry is only allowed when the significance is "patch".' );
268
							}
269
							return $v;
270
						}
271
					);
272
				}
273
				$entry = $this->getHelper( 'question' )->ask( $input, $output, $question );
274
				if ( null === $entry ) {
275
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
276
					return 1;
277
				}
278
			} else {
279
				if ( null === $entry ) {
280
					$output->writeln( '<error>Entry must be specified in non-interactive mode.</>' );
281
					return 1;
282
				}
283
				if ( 'patch' !== $significance && '' === $entry ) {
284
					$output->writeln( '<error>An empty changelog entry is only allowed when the significance is "patch".</>' );
285
					return 1;
286
				}
287
			}
288
289
			// Ask if a change comment is desired, if they left the change entry itself empty.
290
			$comment = (string) $input->getOption( 'comment' );
291
			if ( $isInteractive && '' === $entry ) {
292
				$question = new Question( "You omitted the changelog entry, which is fine. But please comment as to why no entry is needed.\n > ", $comment );
293
				$comment  = $this->getHelper( 'question' )->ask( $input, $output, $question );
294
				if ( null === $comment ) {
295
					$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
296
					return 1; // @codeCoverageIgnore
297
				}
298
			}
299
			$comment = trim( preg_replace( '/\s+/', ' ', $comment ) );
300
			if ( '' !== $comment ) {
301
				$contents .= "Comment: $comment\n";
302
			}
303
304
			$contents .= "\n$entry";
305
306
			// Ok! Write the file.
307
			// Use fopen/fwrite/fclose instead of file_put_contents because the latter doesn't support 'x'.
308
			$output->writeln(
309
				"<info>Creating changelog entry $dir/$filename:\n" . preg_replace( '/^/m', '  ', $contents ) . '</>',
310
				OutputInterface::VERBOSITY_DEBUG
311
			);
312
			$contents .= "\n";
313
			Utils::error_clear_last();
314
			$fp = quietCall( 'fopen', "$dir/$filename", 'x' );
315
			if ( ! $fp ||
316
				quietCall( 'fwrite', $fp, $contents ) !== strlen( $contents ) ||
317
				! quietCall( 'fclose', $fp )
318
			) {
319
				// @codeCoverageIgnoreStart
320
				$err = error_get_last();
321
				$output->writeln( "<error>Failed to write file \"$dir/$filename\": {$err['message']}.</>" );
322
				quietCall( 'fclose', $fp );
323
				quietCall( 'unlink', "$dir/$filename" );
324
				return 1;
325
				// @codeCoverageIgnoreEnd
326
			}
327
328
			return 0;
329
		} 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...
330
			$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
331
			return 1; // @codeCoverageIgnore
332
		}
333
	}
334
}
335