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 ) { |
|
|
|
|
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 ) { |
|
|
|
|
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 |
|
|
|
|
339
|
|
|
$output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore |
340
|
|
|
return 1; // @codeCoverageIgnore |
341
|
|
|
} |
342
|
|
|
} |
343
|
|
|
} |
344
|
|
|
|
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.