Completed
Push — add/changelog-tooling ( 7f5585...86359e )
by
unknown
58:45 queued 48:46
created

AddCommand::getDefaultFilename()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 8
nop 1
dl 0
loc 16
rs 9.7333
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 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