diff --git a/src/lint/linter/ArcanistExternalLinter.php b/src/lint/linter/ArcanistExternalLinter.php index d25c10a2..a394528f 100644 --- a/src/lint/linter/ArcanistExternalLinter.php +++ b/src/lint/linter/ArcanistExternalLinter.php @@ -1,570 +1,570 @@ Mandatory flags, like `"--format=xml"`. * @task bin */ protected function getMandatoryFlags() { return array(); } /** * Provide default, overridable flags to the linter. Generally these are * configuration flags which affect behavior but aren't critical. Flags * which are required should be provided in @{method:getMandatoryFlags} * instead. * * Default flags can be overridden with @{method:setFlags}. * * @return list Overridable default flags. * @task bin */ protected function getDefaultFlags() { return array(); } /** * Override default flags with custom flags. If not overridden, flags provided * by @{method:getDefaultFlags} are used. * * @param list New flags. * @return this * @task bin */ final public function setFlags(array $flags) { $this->flags = $flags; return $this; } /** * Set the binary's version requirement. * * @param string Version requirement. * @return this * @task bin */ final public function setVersionRequirement($version) { $this->versionRequirement = trim($version); return $this; } /** * Return the binary or script to execute. This method synthesizes defaults * and configuration. You can override the binary with @{method:setBinary}. * * @return string Binary to execute. * @task bin */ final public function getBinary() { return coalesce($this->bin, $this->getDefaultBinary()); } /** * Override the default binary with a new one. * * @param string New binary. * @return this * @task bin */ final public function setBinary($bin) { $this->bin = $bin; return $this; } /** * Return true if this linter should use an interpreter (like "python" or * "node") in addition to the script. * * After overriding this method to return `true`, override * @{method:getDefaultInterpreter} to set a default. * * @return bool True to use an interpreter. * @task bin */ public function shouldUseInterpreter() { return false; } /** * Return the default interpreter, like "python" or "node". This method is * only invoked if @{method:shouldUseInterpreter} has been overridden to * return `true`. * * @return string Default interpreter. * @task bin */ public function getDefaultInterpreter() { throw new PhutilMethodNotImplementedException(); } /** * Get the effective interpreter. This method synthesizes configuration and * defaults. * * @return string Effective interpreter. * @task bin */ final public function getInterpreter() { return coalesce($this->interpreter, $this->getDefaultInterpreter()); } /** * Set the interpreter, overriding any default. * * @param string New interpreter. * @return this * @task bin */ final public function setInterpreter($interpreter) { $this->interpreter = $interpreter; return $this; } /* -( Parsing Linter Output )---------------------------------------------- */ /** * Parse the output of the external lint program into objects of class * @{class:ArcanistLintMessage} which `arc` can consume. Generally, this * means examining the output and converting each warning or error into a * message. * * If parsing fails, returning `false` will cause the caller to throw an * appropriate exception. (You can also throw a more specific exception if * you're able to detect a more specific condition.) Otherwise, return a list * of messages. * * @param string Path to the file being linted. * @param int Exit code of the linter. * @param string Stdout of the linter. * @param string Stderr of the linter. * @return list|false List of lint messages, or false * to indicate parser failure. * @task parse */ abstract protected function parseLinterOutput($path, $err, $stdout, $stderr); /* -( Executing the Linter )----------------------------------------------- */ /** * Check that the binary and interpreter (if applicable) exist, and throw * an exception with a message about how to install them if they do not. * * @return void */ final public function checkBinaryConfiguration() { $interpreter = null; if ($this->shouldUseInterpreter()) { $interpreter = $this->getInterpreter(); } $binary = $this->getBinary(); // NOTE: If we have an interpreter, we don't require the script to be // executable (so we just check that the path exists). Otherwise, the // binary must be executable. if ($interpreter) { if (!Filesystem::binaryExists($interpreter)) { throw new ArcanistMissingLinterException( pht( 'Unable to locate interpreter "%s" to run linter %s. You may need '. 'to install the interpreter, or adjust your linter configuration.', $interpreter, get_class($this))); } if (!Filesystem::pathExists($binary)) { throw new ArcanistMissingLinterException( sprintf( "%s\n%s", pht( 'Unable to locate script "%s" to run linter %s. You may need '. 'to install the script, or adjust your linter configuration.', $binary, get_class($this)), pht( 'TO INSTALL: %s', $this->getInstallInstructions()))); } } else { if (!Filesystem::binaryExists($binary)) { throw new ArcanistMissingLinterException( sprintf( "%s\n%s", pht( 'Unable to locate binary "%s" to run linter %s. You may need '. 'to install the binary, or adjust your linter configuration.', $binary, get_class($this)), pht( 'TO INSTALL: %s', $this->getInstallInstructions()))); } } } /** * If a binary version requirement has been specified, compare the version * of the configured binary to the required version, and if the binary's * version is not supported, throw an exception. * * @param string Version string to check. * @return void */ final protected function checkBinaryVersion($version) { if (!$this->versionRequirement) { return; } if (!$version) { $message = pht( 'Linter %s requires %s version %s. Unable to determine the version '. 'that you have installed.', get_class($this), $this->getBinary(), $this->versionRequirement); $instructions = $this->getUpgradeInstructions(); if ($instructions) { $message .= "\n".pht('TO UPGRADE: %s', $instructions); } throw new ArcanistMissingLinterException($message); } $operator = '=='; $compare_to = $this->versionRequirement; $matches = null; if (preg_match('/^([<>]=?|=)\s*(.*)$/', $compare_to, $matches)) { $operator = $matches[1]; $compare_to = $matches[2]; if ($operator === '=') { $operator = '=='; } } if (!version_compare($version, $compare_to, $operator)) { $message = pht( 'Linter %s requires %s version %s. You have version %s.', get_class($this), $this->getBinary(), $this->versionRequirement, $version); $instructions = $this->getUpgradeInstructions(); if ($instructions) { $message .= "\n".pht('TO UPGRADE: %s', $instructions); } throw new ArcanistMissingLinterException($message); } } /** * Get the composed executable command, including the interpreter and binary * but without flags or paths. This can be used to execute `--version` * commands. * * @return string Command to execute the raw linter. * @task exec */ final protected function getExecutableCommand() { $this->checkBinaryConfiguration(); $interpreter = null; if ($this->shouldUseInterpreter()) { $interpreter = $this->getInterpreter(); } $binary = $this->getBinary(); if ($interpreter) { $bin = csprintf('%s %s', $interpreter, $binary); } else { $bin = csprintf('%s', $binary); } return $bin; } /** * Get the composed flags for the executable, including both mandatory and * configured flags. * * @return list Composed flags. * @task exec */ final protected function getCommandFlags() { return array_merge( $this->getMandatoryFlags(), nonempty($this->flags, $this->getDefaultFlags())); } public function getCacheVersion() { try { $this->checkBinaryConfiguration(); } catch (ArcanistMissingLinterException $e) { return null; } $version = $this->getVersion(); if ($version) { $this->checkBinaryVersion($version); return $version.'-'.json_encode($this->getCommandFlags()); } else { // Either we failed to parse the version number or the `getVersion` // function hasn't been implemented. return json_encode($this->getCommandFlags()); } } /** * Prepare the path to be added to the command string. * * This method is expected to return an already escaped string. * * @param string Path to the file being linted * @return string The command-ready file argument */ protected function getPathArgumentForLinterFuture($path) { return csprintf('%s', $path); } - final protected function buildFutures(array $paths) { + protected function buildFutures(array $paths) { $executable = $this->getExecutableCommand(); $bin = csprintf('%C %Ls', $executable, $this->getCommandFlags()); $futures = array(); foreach ($paths as $path) { $disk_path = $this->getEngine()->getFilePathOnDisk($path); $path_argument = $this->getPathArgumentForLinterFuture($disk_path); $future = new ExecFuture('%C %C', $bin, $path_argument); $future->setCWD($this->getProjectRoot()); $futures[$path] = $future; } return $futures; } - final protected function resolveFuture($path, Future $future) { + protected function resolveFuture($path, Future $future) { list($err, $stdout, $stderr) = $future->resolve(); if ($err && !$this->shouldExpectCommandErrors()) { $future->resolvex(); } $messages = $this->parseLinterOutput($path, $err, $stdout, $stderr); if ($err && $this->shouldExpectCommandErrors() && !$messages) { // We assume that if the future exits with a non-zero status and we // failed to parse any linter messages, then something must've gone wrong // during parsing. $messages = false; } if ($messages === false) { if ($err) { $future->resolvex(); } else { throw new Exception( sprintf( "%s\n\nSTDOUT\n%s\n\nSTDERR\n%s", pht('Linter failed to parse output!'), $stdout, $stderr)); } } foreach ($messages as $message) { $this->addLintMessage($message); } } public function getLinterConfigurationOptions() { $options = array( 'bin' => array( 'type' => 'optional string | list', 'help' => pht( 'Specify a string (or list of strings) identifying the binary '. 'which should be invoked to execute this linter. This overrides '. 'the default binary. If you provide a list of possible binaries, '. 'the first one which exists will be used.'), ), 'flags' => array( 'type' => 'optional list', 'help' => pht( 'Provide a list of additional flags to pass to the linter on the '. 'command line.'), ), 'version' => array( 'type' => 'optional string', 'help' => pht( 'Specify a version requirement for the binary. The version number '. 'may be prefixed with <, <=, >, >=, or = to specify the version '. 'comparison operator (default: =).'), ), ); if ($this->shouldUseInterpreter()) { $options['interpreter'] = array( 'type' => 'optional string | list', 'help' => pht( 'Specify a string (or list of strings) identifying the interpreter '. 'which should be used to invoke the linter binary. If you provide '. 'a list of possible interpreters, the first one that exists '. 'will be used.'), ); } return $options + parent::getLinterConfigurationOptions(); } public function setLinterConfigurationValue($key, $value) { switch ($key) { case 'interpreter': $root = $this->getProjectRoot(); foreach ((array)$value as $path) { if (Filesystem::binaryExists($path)) { $this->setInterpreter($path); return; } $path = Filesystem::resolvePath($path, $root); if (Filesystem::binaryExists($path)) { $this->setInterpreter($path); return; } } throw new Exception( pht('None of the configured interpreters can be located.')); case 'bin': $is_script = $this->shouldUseInterpreter(); $root = $this->getProjectRoot(); foreach ((array)$value as $path) { if (!$is_script && Filesystem::binaryExists($path)) { $this->setBinary($path); return; } $path = Filesystem::resolvePath($path, $root); if ((!$is_script && Filesystem::binaryExists($path)) || ($is_script && Filesystem::pathExists($path))) { $this->setBinary($path); return; } } throw new Exception( pht('None of the configured binaries can be located.')); case 'flags': $this->setFlags($value); return; case 'version': $this->setVersionRequirement($value); return; } return parent::setLinterConfigurationValue($key, $value); } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php index 6bb02e5d..afb8cbe3 100644 --- a/src/lint/linter/ArcanistLinter.php +++ b/src/lint/linter/ArcanistLinter.php @@ -1,630 +1,627 @@ A mapping of header to body content for the * additional information sections. * @task info */ public function getAdditionalInformation() { return array(); } /** * Return a human-readable linter name. * * These are used by `arc linters`, and can let you give a linter a more * presentable name. * * @return string Human-readable linter name. * @task info */ public function getInfoName() { return nonempty( $this->getLinterName(), $this->getLinterConfigurationName(), get_class($this)); } /* -( Runtime State )------------------------------------------------------ */ /** * @task state */ final public function getActivePath() { return $this->activePath; } /** * @task state */ final public function setActivePath($path) { $this->stopAllLinters = false; $this->activePath = $path; return $this; } /** * @task state */ final public function setEngine(ArcanistLintEngine $engine) { $this->engine = $engine; return $this; } /** * @task state */ final protected function getEngine() { return $this->engine; } /** * Set the internal ID for this linter. * * This ID is assigned automatically by the @{class:ArcanistLintEngine}. * * @param string Unique linter ID. * @return this * @task state */ final public function setLinterID($id) { $this->id = $id; return $this; } /** * Get the internal ID for this linter. * * Retrieves an internal linter ID managed by the @{class:ArcanistLintEngine}. * This ID is a unique scalar which distinguishes linters in a list. * * @return string Unique linter ID. * @task state */ final public function getLinterID() { return $this->id; } /* -( Executing Linters )-------------------------------------------------- */ /** * Hook called before a list of paths are linted. * * Parallelizable linters can start multiple requests in parallel here, * to improve performance. They can implement @{method:didLintPaths} to * collect results. * * Linters which are not parallelizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths to be linted * @return void * @task exec */ public function willLintPaths(array $paths) { return; } /** * Hook called for each path to be linted. * * Linters which are not parallelizable can do work here. * * Linters which are parallelizable may want to ignore this callback and * implement @{method:willLintPaths} and @{method:didLintPaths} instead. * * @param string Path to lint. * @return void * @task exec */ public function lintPath($path) { return; } /** * Hook called after a list of paths are linted. * * Parallelizable linters can collect results here. * * Linters which are not paralleizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths which were linted. * @return void * @task exec */ public function didLintPaths(array $paths) { return; } public function getLinterPriority() { return 1.0; } - /** - * TODO: This should be `final`. - */ public function setCustomSeverityMap(array $map) { $this->customSeverityMap = $map; return $this; } public function addCustomSeverityMap(array $map) { $this->customSeverityMap = $this->customSeverityMap + $map; return $this; } - final public function setCustomSeverityRules(array $rules) { + public function setCustomSeverityRules(array $rules) { $this->customSeverityRules = $rules; return $this; } final public function getProjectRoot() { $engine = $this->getEngine(); if (!$engine) { throw new Exception( pht( 'You must call %s before you can call %s.', 'setEngine()', __FUNCTION__.'()')); } $working_copy = $engine->getWorkingCopy(); if (!$working_copy) { return null; } return $working_copy->getProjectRoot(); } final public function getOtherLocation($offset, $path = null) { if ($path === null) { $path = $this->getActivePath(); } list($line, $char) = $this->getEngine()->getLineAndCharFromOffset( $path, $offset); return array( 'path' => $path, 'line' => $line + 1, 'char' => $char, ); } final public function stopAllLinters() { $this->stopAllLinters = true; return $this; } final public function didStopAllLinters() { return $this->stopAllLinters; } final public function addPath($path) { $this->paths[$path] = $path; $this->filteredPaths = null; return $this; } final public function setPaths(array $paths) { $this->paths = $paths; $this->filteredPaths = null; return $this; } /** * Filter out paths which this linter doesn't act on (for example, because * they are binaries and the linter doesn't apply to binaries). * * @param list * @return list */ final private function filterPaths(array $paths) { $engine = $this->getEngine(); $keep = array(); foreach ($paths as $path) { if (!$this->shouldLintDeletedFiles() && !$engine->pathExists($path)) { continue; } if (!$this->shouldLintDirectories() && $engine->isDirectory($path)) { continue; } if (!$this->shouldLintBinaryFiles() && $engine->isBinaryFile($path)) { continue; } if (!$this->shouldLintSymbolicLinks() && $engine->isSymbolicLink($path)) { continue; } $keep[] = $path; } return $keep; } final public function getPaths() { if ($this->filteredPaths === null) { $this->filteredPaths = $this->filterPaths(array_values($this->paths)); } return $this->filteredPaths; } final public function addData($path, $data) { $this->data[$path] = $data; return $this; } final protected function getData($path) { if (!array_key_exists($path, $this->data)) { $this->data[$path] = $this->getEngine()->loadData($path); } return $this->data[$path]; } public function getCacheVersion() { return 0; } final public function getLintMessageFullCode($short_code) { return $this->getLinterName().$short_code; } final public function getLintMessageSeverity($code) { $map = $this->customSeverityMap; if (isset($map[$code])) { return $map[$code]; } foreach ($this->customSeverityRules as $rule => $severity) { if (preg_match($rule, $code)) { return $severity; } } $map = $this->getLintSeverityMap(); if (isset($map[$code])) { return $map[$code]; } return $this->getDefaultMessageSeverity($code); } protected function getDefaultMessageSeverity($code) { return ArcanistLintSeverity::SEVERITY_ERROR; } final public function isMessageEnabled($code) { return ($this->getLintMessageSeverity($code) !== ArcanistLintSeverity::SEVERITY_DISABLED); } final public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return pht('Unknown lint message!'); } final protected function addLintMessage(ArcanistLintMessage $message) { $root = $this->getProjectRoot(); $path = Filesystem::resolvePath($message->getPath(), $root); $message->setPath(Filesystem::readablePath($path, $root)); $this->messages[] = $message; return $message; } final public function getLintMessages() { return $this->messages; } final public function raiseLintAtLine( $line, $char, $code, $description, $original = null, $replacement = null) { $message = id(new ArcanistLintMessage()) ->setPath($this->getActivePath()) ->setLine($line) ->setChar($char) ->setCode($this->getLintMessageFullCode($code)) ->setSeverity($this->getLintMessageSeverity($code)) ->setName($this->getLintMessageName($code)) ->setDescription($description) ->setOriginalText($original) ->setReplacementText($replacement); return $this->addLintMessage($message); } final public function raiseLintAtPath($code, $desc) { return $this->raiseLintAtLine(null, null, $code, $desc, null, null); } final public function raiseLintAtOffset( $offset, $code, $description, $original = null, $replacement = null) { $path = $this->getActivePath(); $engine = $this->getEngine(); if ($offset === null) { $line = null; $char = null; } else { list($line, $char) = $engine->getLineAndCharFromOffset($path, $offset); } return $this->raiseLintAtLine( $line + 1, $char + 1, $code, $description, $original, $replacement); } public function canRun() { return true; } abstract public function getLinterName(); public function getVersion() { return null; } final protected function isCodeEnabled($code) { $severity = $this->getLintMessageSeverity($code); return $this->getEngine()->isSeverityEnabled($severity); } public function getLintSeverityMap() { return array(); } public function getLintNameMap() { return array(); } public function getCacheGranularity() { return self::GRANULARITY_FILE; } /** * If this linter is selectable via `.arclint` configuration files, return * a short, human-readable name to identify it. For example, `"jshint"` or * `"pep8"`. * * If you do not implement this method, the linter will not be selectable * through `.arclint` files. */ public function getLinterConfigurationName() { return null; } public function getLinterConfigurationOptions() { if (!$this->canCustomizeLintSeverities()) { return array(); } return array( 'severity' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map from lint codes to adjusted severity levels: error, '. 'warning, advice, autofix or disabled.'), ), 'severity.rules' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map of regular expressions to severity levels. All '. 'matching codes have their severity adjusted.'), ), 'standard' => array( 'type' => 'optional string | list', 'help' => pht('The coding standard(s) to apply.'), ), ); } public function setLinterConfigurationValue($key, $value) { $sev_map = array( 'error' => ArcanistLintSeverity::SEVERITY_ERROR, 'warning' => ArcanistLintSeverity::SEVERITY_WARNING, 'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX, 'advice' => ArcanistLintSeverity::SEVERITY_ADVICE, 'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED, ); switch ($key) { case 'severity': if (!$this->canCustomizeLintSeverities()) { break; } $custom = array(); foreach ($value as $code => $severity) { if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } $code = $this->getLintCodeFromLinterConfigurationKey($code); $custom[$code] = $severity; } $this->setCustomSeverityMap($custom); return; case 'severity.rules': if (!$this->canCustomizeLintSeverities()) { break; } foreach ($value as $rule => $severity) { if (@preg_match($rule, '') === false) { throw new Exception( pht( 'Severity rule "%s" is not a valid regular expression.', $rule)); } if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } } $this->setCustomSeverityRules($value); return; case 'standard': $standards = (array)$value; foreach ($standards as $standard_name) { $standard = ArcanistLinterStandard::getStandard( $standard_name, $this); foreach ($standard->getLinterConfiguration() as $k => $v) { $this->setLinterConfigurationValue($k, $v); } $this->addCustomSeverityMap($standard->getLinterSeverityMap()); } return; } throw new Exception(pht('Incomplete implementation: %s!', $key)); } protected function canCustomizeLintSeverities() { return true; } protected function shouldLintBinaryFiles() { return false; } protected function shouldLintDeletedFiles() { return false; } protected function shouldLintDirectories() { return false; } protected function shouldLintSymbolicLinks() { return false; } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/ArcanistPyLintLinter.php b/src/lint/linter/ArcanistPyLintLinter.php index 44793a50..f8cc279a 100644 --- a/src/lint/linter/ArcanistPyLintLinter.php +++ b/src/lint/linter/ArcanistPyLintLinter.php @@ -1,183 +1,183 @@ getExecutableCommand()); $matches = array(); - $regex = '/^pylint (?P\d+\.\d+\.\d+),/'; + $regex = '/^pylint (?P\d+\.\d+\.\d+)/'; if (preg_match($regex, $stdout, $matches)) { return $matches['version']; } else { return false; } } public function getInstallInstructions() { return pht( 'Install PyLint using `%s`.', 'pip install pylint'); } public function shouldExpectCommandErrors() { return true; } public function getLinterConfigurationOptions() { $options = array( 'pylint.config' => array( 'type' => 'optional string', 'help' => pht('Pass in a custom configuration file path.'), ), ); return $options + parent::getLinterConfigurationOptions(); } public function setLinterConfigurationValue($key, $value) { switch ($key) { case 'pylint.config': $this->config = $value; return; default: return parent::setLinterConfigurationValue($key, $value); } } protected function getMandatoryFlags() { $options = array(); $options[] = '--reports=no'; $options[] = '--msg-template={line}|{column}|{msg_id}|{symbol}|{msg}'; // Specify an `--rcfile`, either absolute or relative to the project root. // Stupidly, the command line args above are overridden by rcfile, so be // careful. $config = $this->config; if ($config !== null) { $options[] = '--rcfile='.$config; } return $options; } protected function getDefaultFlags() { $options = array(); $installed_version = $this->getVersion(); $minimum_version = '1.0.0'; if (version_compare($installed_version, $minimum_version, '<')) { throw new ArcanistMissingLinterException( pht( '%s is not compatible with the installed version of pylint. '. 'Minimum version: %s; installed version: %s.', __CLASS__, $minimum_version, $installed_version)); } return $options; } protected function parseLinterOutput($path, $err, $stdout, $stderr) { if ($err === 32) { // According to `man pylint` the exit status of 32 means there was a // usage error. That's bad, so actually exit abnormally. return false; } $lines = phutil_split_lines($stdout, false); $messages = array(); foreach ($lines as $line) { $matches = explode('|', $line, 5); if (count($matches) < 5) { continue; } // NOTE: PyLint sometimes returns -1 as the character offset for a // message. If it does, treat it as 0. See T9257. $char = (int)$matches[1]; $char = max(0, $char); $message = id(new ArcanistLintMessage()) ->setPath($path) ->setLine($matches[0]) ->setChar($char) ->setCode($matches[2]) ->setSeverity($this->getLintMessageSeverity($matches[2])) ->setName(ucwords(str_replace('-', ' ', $matches[3]))) ->setDescription($matches[4]); $messages[] = $message; } return $messages; } protected function getDefaultMessageSeverity($code) { switch (substr($code, 0, 1)) { case 'R': case 'C': return ArcanistLintSeverity::SEVERITY_ADVICE; case 'W': return ArcanistLintSeverity::SEVERITY_WARNING; case 'E': case 'F': return ArcanistLintSeverity::SEVERITY_ERROR; default: return ArcanistLintSeverity::SEVERITY_DISABLED; } } protected function getLintCodeFromLinterConfigurationKey($code) { if (!preg_match('/^(R|C|W|E|F)\d{4}$/', $code)) { throw new Exception( pht( 'Unrecognized lint message code "%s". Expected a valid Pylint '. 'lint code like "%s", or "%s", or "%s".', $code, 'C0111', 'E0602', 'W0611')); } return $code; } } diff --git a/src/lint/linter/__tests__/ArcanistLinterTestCase.php b/src/lint/linter/__tests__/ArcanistLinterTestCase.php index a6eba567..b86a43b2 100644 --- a/src/lint/linter/__tests__/ArcanistLinterTestCase.php +++ b/src/lint/linter/__tests__/ArcanistLinterTestCase.php @@ -1,261 +1,262 @@ getLinter(); $files = id(new FileFinder($root)) ->withType('f') ->withSuffix('lint-test') ->find(); $test_count = 0; foreach ($files as $file) { $this->lintFile($root.$file, $linter); $test_count++; } $this->assertTrue( ($test_count > 0), pht( 'Expected to find some %s tests in directory %s!', '.lint-test', $root)); } private function lintFile($file, ArcanistLinter $linter) { $linter = clone $linter; $contents = Filesystem::readFile($file); $contents = preg_split('/^~{4,}\n/m', $contents); if (count($contents) < 2) { throw new Exception( pht( "Expected '%s' separating test case and results.", '~~~~~~~~~~')); } list($data, $expect, $xform, $config) = array_merge( $contents, array(null, null)); $basename = basename($file); if ($config) { $config = phutil_json_decode($config); } else { $config = array(); } PhutilTypeSpec::checkMap( $config, array( 'config' => 'optional map', 'path' => 'optional string', 'mode' => 'optional string', 'stopped' => 'optional bool', )); $exception = null; $after_lint = null; $messages = null; $exception_message = false; $caught_exception = false; try { $tmp = new TempFile($basename); Filesystem::writeFile($tmp, $data); $full_path = (string)$tmp; $mode = idx($config, 'mode'); if ($mode) { Filesystem::changePermissions($tmp, octdec($mode)); } $dir = dirname($full_path); $path = basename($full_path); $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $dir, null, pht('Unit Test')); $configuration_manager = new ArcanistConfigurationManager(); $configuration_manager->setWorkingCopyIdentity($working_copy); $engine = new ArcanistUnitTestableLintEngine(); $engine->setWorkingCopy($working_copy); $engine->setConfigurationManager($configuration_manager); $path_name = idx($config, 'path', $path); $engine->setPaths(array($path_name)); + $linter->setEngine($engine); $linter->addPath($path_name); $linter->addData($path_name, $data); foreach (idx($config, 'config', array()) as $key => $value) { $linter->setLinterConfigurationValue($key, $value); } $engine->addLinter($linter); $engine->addFileData($path_name, $data); $results = $engine->run(); $this->assertEqual( 1, count($results), pht('Expect one result returned by linter.')); $assert_stopped = idx($config, 'stopped'); if ($assert_stopped !== null) { $this->assertEqual( $assert_stopped, $linter->didStopAllLinters(), $assert_stopped ? pht('Expect linter to be stopped.') : pht('Expect linter to not be stopped.')); } $result = reset($results); $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $after_lint = $patcher->getModifiedFileContent(); } catch (PhutilTestTerminatedException $ex) { throw $ex; } catch (Exception $exception) { $caught_exception = true; if ($exception instanceof PhutilAggregateException) { $caught_exception = false; foreach ($exception->getExceptions() as $ex) { if ($ex instanceof ArcanistUsageException || $ex instanceof ArcanistMissingLinterException) { $this->assertSkipped($ex->getMessage()); } else { $caught_exception = true; } } } else if ($exception instanceof ArcanistUsageException || $exception instanceof ArcanistMissingLinterException) { $this->assertSkipped($exception->getMessage()); } $exception_message = $exception->getMessage()."\n\n". $exception->getTraceAsString(); } $this->assertEqual(false, $caught_exception, $exception_message); $this->compareLint($basename, $expect, $result); $this->compareTransform($xform, $after_lint); } private function compareLint($file, $expect, ArcanistLintResult $result) { $seen = array(); $raised = array(); $message_map = array(); foreach ($result->getMessages() as $message) { $sev = $message->getSeverity(); $line = $message->getLine(); $char = $message->getChar(); $code = $message->getCode(); $name = $message->getName(); $message_key = $sev.':'.$line.':'.$char; $message_map[$message_key] = $message; $seen[] = $message_key; $raised[] = sprintf( ' %s: %s %s', pht('%s at line %d, char %d', $sev, $line, $char), $code, $name); } $expect = trim($expect); if ($expect) { $expect = explode("\n", $expect); } else { $expect = array(); } foreach ($expect as $key => $expected) { $expect[$key] = head(explode(' ', $expected)); } $expect = array_fill_keys($expect, true); $seen = array_fill_keys($seen, true); if (!$raised) { $raised = array(pht('No messages.')); } $raised = sprintf( "%s:\n%s", pht('Actually raised'), implode("\n", $raised)); foreach (array_diff_key($expect, $seen) as $missing => $ignored) { $missing = explode(':', $missing); $sev = array_shift($missing); $pos = $missing; $this->assertFailure( pht( "In '%s', expected lint to raise %s on line %d at char %d, ". "but no %s was raised. %s", $file, $sev, idx($pos, 0), idx($pos, 1), $sev, $raised)); } foreach (array_diff_key($seen, $expect) as $surprising => $ignored) { $message = $message_map[$surprising]; $message_info = $message->getDescription(); list($sev, $line, $char) = explode(':', $surprising); $this->assertFailure( sprintf( "%s:\n\n%s\n\n%s", pht( "In '%s', lint raised %s on line %d at char %d, ". "but nothing was expected", $file, $sev, $line, $char), $message_info, $raised)); } } private function compareTransform($expected, $actual) { if (!strlen($expected)) { return; } $this->assertEqual( $expected, $actual, pht('File as patched by lint did not match the expected patched file.')); } } diff --git a/src/lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php index a22a9859..288fc05c 100644 --- a/src/lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php +++ b/src/lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php @@ -1,49 +1,52 @@ selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($class_declarations as $class_declaration) { + if ($class_declaration->getChildByIndex(1)->getTypeName() == 'n_EMPTY') { + continue; + } $class_name = $class_declaration ->getChildOfType(1, 'n_CLASS_NAME') ->getConcreteString(); $strings = $class_declaration->selectDescendantsOfType('n_STRING_SCALAR'); foreach ($strings as $string) { $contents = substr($string->getSemanticString(), 1, -1); $replacement = null; if ($contents == $class_name) { $replacement = '__CLASS__'; } $regex = '/\b'.preg_quote($class_name, '/').'\b/'; if (!preg_match($regex, $contents)) { continue; } $this->raiseLintAtNode( $string, pht( "Don't hard-code class names, use `%s` instead.", '__CLASS__'), $replacement); } } } } diff --git a/src/lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php index d18f0335..4179c899 100644 --- a/src/lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php +++ b/src/lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php @@ -1,32 +1,34 @@ selectDescendantsOfType('n_NEW'); foreach ($nodes as $node) { $class = $node->getChildByIndex(0); $params = $node->getChildByIndex(1); - if ($params->getTypeName() == 'n_EMPTY') { + if ($class->getTypeName() != 'n_CLASS_DECLARATION' && + $params->getTypeName() == 'n_EMPTY') { + $this->raiseLintAtNode( $class, pht('Use parentheses when invoking a constructor.'), $class->getConcreteString().'()'); } } } } diff --git a/src/lint/linter/xhpast/rules/ArcanistSelfClassReferenceXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistSelfClassReferenceXHPASTLinterRule.php index 7ad7c238..c87974d7 100644 --- a/src/lint/linter/xhpast/rules/ArcanistSelfClassReferenceXHPASTLinterRule.php +++ b/src/lint/linter/xhpast/rules/ArcanistSelfClassReferenceXHPASTLinterRule.php @@ -1,45 +1,48 @@ selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($class_declarations as $class_declaration) { + if ($class_declaration->getChildByIndex(1)->getTypeName() == 'n_EMPTY') { + continue; + } $class_name = $class_declaration ->getChildOfType(1, 'n_CLASS_NAME') ->getConcreteString(); $instantiations = $class_declaration ->selectDescendantsOfType('n_NEW'); foreach ($instantiations as $instantiation) { $type = $instantiation->getChildByIndex(0); if ($type->getTypeName() != 'n_CLASS_NAME') { continue; } if (strtolower($type->getConcreteString()) == strtolower($class_name)) { $this->raiseLintAtNode( $type, pht( 'Use `%s` to instantiate the current class.', 'self'), 'self'); } } } } } diff --git a/src/lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php index 1abbaa01..5c197a60 100644 --- a/src/lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php +++ b/src/lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php @@ -1,52 +1,55 @@ selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { + if ($class->getChildByIndex(0)->getTypeName() == 'n_EMPTY') { + continue; + } $attributes = $class->getChildOfType(0, 'n_CLASS_ATTRIBUTES'); $is_final = false; foreach ($attributes->getChildren() as $attribute) { if ($attribute->getConcreteString() == 'final') { $is_final = true; break; } } if (!$is_final) { continue; } $methods = $class->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $attributes = $method->getChildOfType(0, 'n_METHOD_MODIFIER_LIST'); foreach ($attributes->getChildren() as $attribute) { if ($attribute->getConcreteString() == 'final') { $this->raiseLintAtNode( $attribute, pht( 'Unnecessary `%s` modifier in `%s` class.', 'final', 'final')); } } } } } } diff --git a/src/lint/linter/xhpast/rules/__tests__/class-name-literal/class-name-literal.lint-test b/src/lint/linter/xhpast/rules/__tests__/class-name-literal/class-name-literal.lint-test index f6050e6f..263fa6bc 100644 --- a/src/lint/linter/xhpast/rules/__tests__/class-name-literal/class-name-literal.lint-test +++ b/src/lint/linter/xhpast/rules/__tests__/class-name-literal/class-name-literal.lint-test @@ -1,30 +1,42 @@ revision; } public function getWorkflowName() { return 'land'; } public function getCommandSynopses() { return phutil_console_format(<< array( 'param' => 'master', 'help' => pht( "Land feature branch onto a branch other than the default ". "('master' in git, 'default' in hg). You can change the default ". "by setting '%s' with `%s` or for the entire project in %s.", 'arc.land.onto.default', 'arc set-config', '.arcconfig'), ), 'hold' => array( 'help' => pht( 'Prepare the change to be pushed, but do not actually push it.'), ), 'keep-branch' => array( 'help' => pht( 'Keep the feature branch after pushing changes to the '. 'remote (by default, it is deleted).'), ), 'remote' => array( 'param' => 'origin', 'help' => pht( "Push to a remote other than the default ('origin' in git)."), ), 'merge' => array( 'help' => pht( 'Perform a %s merge, not a %s merge. If the project '. 'is marked as having an immutable history, this is the default '. 'behavior.', '--no-ff', '--squash'), 'supports' => array( 'git', ), 'nosupport' => array( 'hg' => pht( 'Use the %s strategy when landing in mercurial.', '--squash'), ), ), 'squash' => array( 'help' => pht( 'Perform a %s merge, not a %s merge. If the project is '. 'marked as having a mutable history, this is the default behavior.', '--squash', '--no-ff'), 'conflicts' => array( 'merge' => pht( '%s and %s are conflicting merge strategies.', '--merge', '--squash'), ), ), 'delete-remote' => array( 'help' => pht( 'Delete the feature branch in the remote after landing it.'), 'conflicts' => array( 'keep-branch' => true, ), 'supports' => array( 'hg', ), ), 'revision' => array( 'param' => 'id', 'help' => pht( 'Use the message from a specific revision, rather than '. 'inferring the revision based on branch content.'), ), 'preview' => array( 'help' => pht( 'Prints the commits that would be landed. Does not '. 'actually modify or land the commits.'), ), '*' => 'branch', ); } public function run() { $this->readArguments(); $engine = null; if ($this->isGit && !$this->isGitSvn) { $engine = new ArcanistGitLandEngine(); } if ($engine) { $this->readEngineArguments(); $this->requireCleanWorkingCopy(); $should_hold = $this->getArgument('hold'); $engine ->setWorkflow($this) ->setRepositoryAPI($this->getRepositoryAPI()) ->setSourceRef($this->branch) ->setTargetRemote($this->remote) ->setTargetOnto($this->onto) ->setShouldHold($should_hold) ->setShouldKeep($this->keepBranch) ->setShouldSquash($this->useSquash) ->setShouldPreview($this->preview) ->setBuildMessageCallback(array($this, 'buildEngineMessage')); $engine->execute(); if (!$should_hold && !$this->preview) { $this->didPush(); } return 0; } $this->validate(); try { $this->pullFromRemote(); } catch (Exception $ex) { $this->restoreBranch(); throw $ex; } $this->printPendingCommits(); if ($this->preview) { $this->restoreBranch(); return 0; } $this->checkoutBranch(); $this->findRevision(); if ($this->useSquash) { $this->rebase(); $this->squash(); } else { $this->merge(); } $this->push(); if (!$this->keepBranch) { $this->cleanupBranch(); } if ($this->oldBranch != $this->onto) { // If we were on some branch A and the user ran "arc land B", // switch back to A. if ($this->keepBranch || $this->oldBranch != $this->branch) { $this->restoreBranch(); } } echo pht('Done.'), "\n"; return 0; } private function getUpstreamMatching($branch, $pattern) { if ($this->isGit) { $repository_api = $this->getRepositoryAPI(); list($err, $fullname) = $repository_api->execManualLocal( 'rev-parse --symbolic-full-name %s@{upstream}', $branch); if (!$err) { $matches = null; if (preg_match($pattern, $fullname, $matches)) { return last($matches); } } } return null; } private function readEngineArguments() { // NOTE: This is hard-coded for Git right now. // TODO: Clean this up and move it into LandEngines. $onto = $this->getEngineOnto(); $remote = $this->getEngineRemote(); // This just overwrites work we did earlier, but it has to be up in this // class for now because other parts of the workflow still depend on it. $this->onto = $onto; $this->remote = $remote; $this->ontoRemoteBranch = $this->remote.'/'.$onto; } private function getEngineOnto() { $onto = $this->getArgument('onto'); if ($onto !== null) { $this->writeInfo( pht('TARGET'), pht( 'Landing onto "%s", selected by the --onto flag.', $onto)); return $onto; } $api = $this->getRepositoryAPI(); $path = $api->getPathToUpstream($this->branch); if ($path->getLength()) { $cycle = $path->getCycle(); if ($cycle) { $this->writeWarn( pht('LOCAL CYCLE'), pht( 'Local branch tracks an upstream, but following it leads to a '. 'local cycle; ignoring branch upstream.')); echo tsprintf( "\n %s\n\n", implode(' -> ', $cycle)); } else { if ($path->isConnectedToRemote()) { $onto = $path->getRemoteBranchName(); $this->writeInfo( pht('TARGET'), pht( 'Landing onto "%s", selected by following tracking branches '. 'upstream to the closest remote.', $onto)); return $onto; } else { $this->writeInfo( pht('NO PATH TO UPSTREAM'), pht( 'Local branch tracks an upstream, but there is no path '. 'to a remote; ignoring branch upstream.')); } } } $config_key = 'arc.land.onto.default'; $onto = $this->getConfigFromAnySource($config_key); if ($onto !== null) { $this->writeInfo( pht('TARGET'), pht( 'Landing onto "%s", selected by "%s" configuration.', $onto, $config_key)); return $onto; } $onto = 'master'; $this->writeInfo( pht('TARGET'), pht( 'Landing onto "%s", the default target under git.', $onto)); return $onto; } private function getEngineRemote() { $remote = $this->getArgument('remote'); if ($remote !== null) { $this->writeInfo( pht('REMOTE'), pht( 'Using remote "%s", selected by the --remote flag.', $remote)); return $remote; } $api = $this->getRepositoryAPI(); $path = $api->getPathToUpstream($this->branch); $remote = $path->getRemoteRemoteName(); if ($remote !== null) { $this->writeInfo( pht('REMOTE'), pht( 'Using remote "%s", selected by following tracking branches '. 'upstream to the closest remote.', $remote)); return $remote; } $remote = 'origin'; $this->writeInfo( pht('REMOTE'), pht( 'Using remote "%s", the default remote under git.', $remote)); return $remote; } private function readArguments() { $repository_api = $this->getRepositoryAPI(); $this->isGit = $repository_api instanceof ArcanistGitAPI; $this->isHg = $repository_api instanceof ArcanistMercurialAPI; if ($this->isGit) { $repository = $this->loadProjectRepository(); $this->isGitSvn = (idx($repository, 'vcs') == 'svn'); } if ($this->isHg) { $this->isHgSvn = $repository_api->isHgSubversionRepo(); } $branch = $this->getArgument('branch'); if (empty($branch)) { $branch = $this->getBranchOrBookmark(); if ($branch !== null) { $this->branchType = $this->getBranchType($branch); // TODO: This message is misleading when landing a detached head or // a tag in Git. echo pht("Landing current %s '%s'.", $this->branchType, $branch), "\n"; $branch = array($branch); } } if (count($branch) !== 1) { throw new ArcanistUsageException( pht('Specify exactly one branch or bookmark to land changes from.')); } $this->branch = head($branch); $this->keepBranch = $this->getArgument('keep-branch'); $this->preview = $this->getArgument('preview'); if (!$this->branchType) { $this->branchType = $this->getBranchType($this->branch); } $onto_default = $this->isGit ? 'master' : 'default'; $onto_default = nonempty( $this->getConfigFromAnySource('arc.land.onto.default'), $onto_default); $onto_default = coalesce( $this->getUpstreamMatching($this->branch, '/^refs\/heads\/(.+)$/'), $onto_default); $this->onto = $this->getArgument('onto', $onto_default); $this->ontoType = $this->getBranchType($this->onto); $remote_default = $this->isGit ? 'origin' : ''; $remote_default = coalesce( $this->getUpstreamMatching($this->onto, '/^refs\/remotes\/(.+?)\//'), $remote_default); $this->remote = $this->getArgument('remote', $remote_default); if ($this->getArgument('merge')) { $this->useSquash = false; } else if ($this->getArgument('squash')) { $this->useSquash = true; } else { $this->useSquash = !$this->isHistoryImmutable(); } $this->ontoRemoteBranch = $this->onto; if ($this->isGitSvn) { $this->ontoRemoteBranch = 'trunk'; } else if ($this->isGit) { $this->ontoRemoteBranch = $this->remote.'/'.$this->onto; } $this->oldBranch = $this->getBranchOrBookmark(); } private function validate() { $repository_api = $this->getRepositoryAPI(); if ($this->onto == $this->branch) { $message = pht( "You can not land a %s onto itself -- you are trying ". "to land '%s' onto '%s'. For more information on how to push ". "changes, see 'Pushing and Closing Revisions' in 'Arcanist User ". "Guide: arc diff' in the documentation.", $this->branchType, $this->branch, $this->onto); if (!$this->isHistoryImmutable()) { $message .= ' '.pht("You may be able to '%s' instead.", 'arc amend'); } throw new ArcanistUsageException($message); } if ($this->isHg) { if ($this->useSquash) { if (!$repository_api->supportsRebase()) { throw new ArcanistUsageException( pht( 'You must enable the rebase extension to use the %s strategy.', '--squash')); } } if ($this->branchType != $this->ontoType) { throw new ArcanistUsageException(pht( 'Source %s is a %s but destination %s is a %s. When landing a '. '%s, the destination must also be a %s. Use %s to specify a %s, '. 'or set %s in %s.', $this->branch, $this->branchType, $this->onto, $this->ontoType, $this->branchType, $this->branchType, '--onto', $this->branchType, 'arc.land.onto.default', '.arcconfig')); } } if ($this->isGit) { list($err) = $repository_api->execManualLocal( 'rev-parse --verify %s', $this->branch); if ($err) { throw new ArcanistUsageException( pht("Branch '%s' does not exist.", $this->branch)); } } $this->requireCleanWorkingCopy(); } private function checkoutBranch() { $repository_api = $this->getRepositoryAPI(); if ($this->getBranchOrBookmark() != $this->branch) { $repository_api->execxLocal('checkout %s', $this->branch); } switch ($this->branchType) { case self::REFTYPE_BOOKMARK: $message = pht( 'Switched to bookmark **%s**. Identifying and merging...', $this->branch); break; case self::REFTYPE_BRANCH: default: $message = pht( 'Switched to branch **%s**. Identifying and merging...', $this->branch); break; } echo phutil_console_format($message."\n"); } private function printPendingCommits() { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { list($out) = $repository_api->execxLocal( 'log --oneline %s %s --', $this->branch, '^'.$this->onto); } else if ($repository_api instanceof ArcanistMercurialAPI) { $common_ancestor = $repository_api->getCanonicalRevisionName( hgsprintf('ancestor(%s,%s)', $this->onto, $this->branch)); $branch_range = hgsprintf( 'reverse((%s::%s) - %s)', $common_ancestor, $this->branch, $common_ancestor); list($out) = $repository_api->execxLocal( 'log -r %s --template %s', $branch_range, '{node|short} {desc|firstline}\n'); } if (!trim($out)) { $this->restoreBranch(); throw new ArcanistUsageException( pht('No commits to land from %s.', $this->branch)); } echo pht("The following commit(s) will be landed:\n\n%s", $out), "\n"; } private function findRevision() { $repository_api = $this->getRepositoryAPI(); $this->parseBaseCommitArgument(array($this->ontoRemoteBranch)); $revision_id = $this->getArgument('revision'); if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (!$revisions) { throw new ArcanistUsageException(pht( "No such revision '%s'!", "D{$revision_id}")); } } else { $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array()); } if (!count($revisions)) { throw new ArcanistUsageException(pht( "arc can not identify which revision exists on %s '%s'. Update the ". "revision with recent changes to synchronize the %s name and hashes, ". "or use '%s' to amend the commit message at HEAD, or use ". "'%s' to select a revision explicitly.", $this->branchType, $this->branch, $this->branchType, 'arc amend', '--revision ')); } else if (count($revisions) > 1) { switch ($this->branchType) { case self::REFTYPE_BOOKMARK: $message = pht( "There are multiple revisions on feature bookmark '%s' which are ". "not present on '%s':\n\n". "%s\n". 'Separate these revisions onto different bookmarks, or use '. '--revision to use the commit message from '. 'and land them all.', $this->branch, $this->onto, $this->renderRevisionList($revisions)); break; case self::REFTYPE_BRANCH: default: $message = pht( "There are multiple revisions on feature branch '%s' which are ". "not present on '%s':\n\n". "%s\n". 'Separate these revisions onto different branches, or use '. '--revision to use the commit message from '. 'and land them all.', $this->branch, $this->onto, $this->renderRevisionList($revisions)); break; } throw new ArcanistUsageException($message); } $this->revision = head($revisions); $rev_status = $this->revision['status']; $rev_id = $this->revision['id']; $rev_title = $this->revision['title']; $rev_auxiliary = idx($this->revision, 'auxiliary', array()); $full_name = pht('D%d: %s', $rev_id, $rev_title); if ($this->revision['authorPHID'] != $this->getUserPHID()) { $other_author = $this->getConduit()->callMethodSynchronous( 'user.query', array( 'phids' => array($this->revision['authorPHID']), )); $other_author = ipull($other_author, 'userName', 'phid'); $other_author = $other_author[$this->revision['authorPHID']]; $ok = phutil_console_confirm(pht( "This %s has revision '%s' but you are not the author. Land this ". "revision by %s?", $this->branchType, $full_name, $other_author)); if (!$ok) { throw new ArcanistUserAbortException(); } } $state_warning = null; $state_header = null; if ($rev_status == ArcanistDifferentialRevisionStatus::CHANGES_PLANNED) { $state_header = pht('REVISION HAS CHANGES PLANNED'); $state_warning = pht( 'The revision you are landing ("%s") is currently in the "%s" state, '. 'indicating that you expect to revise it before moving forward.'. "\n\n". 'Normally, you should resubmit it for review and wait until it is '. '"%s" by reviewers before you continue.'. "\n\n". 'To resubmit the revision for review, either: update the revision '. 'with revised changes; or use "Request Review" from the web interface.', $full_name, pht('Changes Planned'), pht('Accepted')); } else if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) { $state_header = pht('REVISION HAS NOT BEEN ACCEPTED'); $state_warning = pht( 'The revision you are landing ("%s") has not been "%s" by reviewers.', $full_name, pht('Accepted')); } if ($state_warning !== null) { $prompt = pht('Land revision in the wrong state?'); id(new PhutilConsoleBlock()) ->addParagraph(tsprintf('** %s **', $state_header)) ->addParagraph(tsprintf('%B', $state_warning)) ->draw(); $ok = phutil_console_confirm($prompt); if (!$ok) { throw new ArcanistUserAbortException(); } } if ($rev_auxiliary) { $phids = idx($rev_auxiliary, 'phabricator:depends-on', array()); if ($phids) { $dep_on_revs = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'phids' => $phids, 'status' => 'status-open', )); $open_dep_revs = array(); foreach ($dep_on_revs as $dep_on_rev) { $dep_on_rev_id = $dep_on_rev['id']; $dep_on_rev_title = $dep_on_rev['title']; $dep_on_rev_status = $dep_on_rev['status']; $open_dep_revs[$dep_on_rev_id] = $dep_on_rev_title; } if (!empty($open_dep_revs)) { $open_revs = array(); foreach ($open_dep_revs as $id => $title) { $open_revs[] = ' - D'.$id.': '.$title; } $open_revs = implode("\n", $open_revs); echo pht( "Revision '%s' depends on open revisions:\n\n%s", "D{$rev_id}: {$rev_title}", $open_revs); $ok = phutil_console_confirm(pht('Continue anyway?')); if (!$ok) { throw new ArcanistUserAbortException(); } } } } $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $rev_id, )); $this->messageFile = new TempFile(); Filesystem::writeFile($this->messageFile, $message); echo pht( "Landing revision '%s'...", "D{$rev_id}: {$rev_title}")."\n"; $diff_phid = idx($this->revision, 'activeDiffPHID'); if ($diff_phid) { $this->checkForBuildables($diff_phid); } } private function pullFromRemote() { $repository_api = $this->getRepositoryAPI(); $local_ahead_of_remote = false; if ($this->isGit) { $repository_api->execxLocal('checkout %s', $this->onto); echo phutil_console_format(pht( "Switched to branch **%s**. Updating branch...\n", $this->onto)); try { $repository_api->execxLocal('pull --ff-only --no-stat'); } catch (CommandException $ex) { if (!$this->isGitSvn) { throw $ex; } } list($out) = $repository_api->execxLocal( 'log %s..%s', $this->ontoRemoteBranch, $this->onto); if (strlen(trim($out))) { $local_ahead_of_remote = true; } else if ($this->isGitSvn) { $repository_api->execxLocal('svn rebase'); } } else if ($this->isHg) { echo phutil_console_format(pht('Updating **%s**...', $this->onto)."\n"); try { list($out, $err) = $repository_api->execxLocal('pull'); $divergedbookmark = $this->onto.'@'.$repository_api->getBranchName(); if (strpos($err, $divergedbookmark) !== false) { throw new ArcanistUsageException(phutil_console_format(pht( "Local bookmark **%s** has diverged from the server's **%s** ". "(now labeled **%s**). Please resolve this divergence and run ". "'%s' again.", $this->onto, $this->onto, $divergedbookmark, 'arc land'))); } } catch (CommandException $ex) { $err = $ex->getError(); $stdout = $ex->getStdout(); // Copied from: PhabricatorRepositoryPullLocalDaemon.php // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the // behavior of "hg pull" to return 1 in case of a successful pull // with no changes. This behavior has been reverted, but users who // updated between Feb 1, 2012 and Mar 1, 2012 will have the // erroring version. Do a dumb test against stdout to check for this // possibility. // See: https://github.com/phacility/phabricator/issues/101/ // NOTE: Mercurial has translated versions, which translate this error // string. In a translated version, the string will be something else, // like "aucun changement trouve". There didn't seem to be an easy way // to handle this (there are hard ways but this is not a common // problem and only creates log spam, not application failures). // Assume English. // TODO: Remove this once we're far enough in the future that // deployment of 2.1 is exceedingly rare? if ($err != 1 || !preg_match('/no changes found/', $stdout)) { throw $ex; } } // Pull succeeded. Now make sure master is not on an outgoing change if ($repository_api->supportsPhases()) { list($out) = $repository_api->execxLocal( 'log -r %s --template %s', $this->onto, '{phase}'); if ($out != 'public') { $local_ahead_of_remote = true; } } else { // execManual instead of execx because outgoing returns // code 1 when there is nothing outgoing list($err, $out) = $repository_api->execManualLocal( 'outgoing -r %s', $this->onto); // $err === 0 means something is outgoing if ($err === 0) { $local_ahead_of_remote = true; } } } if ($local_ahead_of_remote) { throw new ArcanistUsageException(pht( "Local %s '%s' is ahead of remote %s '%s', so landing a feature ". "%s would push additional changes. Push or reset the changes in '%s' ". "before running '%s'.", $this->ontoType, $this->onto, $this->ontoType, $this->ontoRemoteBranch, $this->ontoType, $this->onto, 'arc land')); } } private function rebase() { $repository_api = $this->getRepositoryAPI(); chdir($repository_api->getPath()); if ($this->isHg) { $onto_tip = $repository_api->getCanonicalRevisionName($this->onto); $common_ancestor = $repository_api->getCanonicalRevisionName( hgsprintf('ancestor(%s, %s)', $this->onto, $this->branch)); // Only rebase if the local branch is not at the tip of the onto branch. if ($onto_tip != $common_ancestor) { // keep branch here so later we can decide whether to remove it $err = $repository_api->execPassthru( 'rebase -d %s --keepbranches', $this->onto); if ($err) { echo phutil_console_format("%s\n", pht('Aborting rebase')); $repository_api->execManualLocal('rebase --abort'); $this->restoreBranch(); throw new ArcanistUsageException(pht( "'%s' failed and the rebase was aborted. This is most ". "likely due to conflicts. Manually rebase %s onto %s, resolve ". "the conflicts, then run '%s' again.", sprintf('hg rebase %s', $this->onto), $this->branch, $this->onto, 'arc land')); } } } $repository_api->reloadWorkingCopy(); } private function squash() { $repository_api = $this->getRepositoryAPI(); if ($this->isGit) { $repository_api->execxLocal('checkout %s', $this->onto); $repository_api->execxLocal( 'merge --no-stat --squash --ff-only %s', $this->branch); } else if ($this->isHg) { // The hg code is a little more complex than git's because we // need to handle the case where the landing branch has child branches: // -a--------b master // \ // w--x mybranch // \--y subbranch1 // \--z subbranch2 // // arc land --branch mybranch --onto master : // -a--b--wx master // \--y subbranch1 // \--z subbranch2 $branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch); // At this point $this->onto has been pulled from remote and // $this->branch has been rebased on top of onto(by the rebase() // function). So we're guaranteed to have onto as an ancestor of branch // when we use first((onto::branch)-onto) below. $branch_root = $repository_api->getCanonicalRevisionName( hgsprintf('first((%s::%s)-%s)', $this->onto, $this->branch, $this->onto)); $branch_range = hgsprintf( '(%s::%s)', $branch_root, $this->branch); if (!$this->keepBranch) { $this->handleAlternateBranches($branch_root, $branch_range); } // Collapse just the landing branch onto master. // Leave its children on the original branch. $err = $repository_api->execPassthru( 'rebase --collapse --keep --logfile %s -r %s -d %s', $this->messageFile, $branch_range, $this->onto); if ($err) { $repository_api->execManualLocal('rebase --abort'); $this->restoreBranch(); throw new ArcanistUsageException( pht( "Squashing the commits under %s failed. ". "Manually squash your commits and run '%s' again.", $this->branch, 'arc land')); } if ($repository_api->isBookmark($this->branch)) { // a bug in mercurial means bookmarks end up on the revision prior // to the collapse when using --collapse with --keep, // so we manually move them to the correct spots // see: http://bz.selenic.com/show_bug.cgi?id=3716 $repository_api->execxLocal( 'bookmark -f %s', $this->onto); $repository_api->execxLocal( 'bookmark -f %s -r %s', $this->branch, $branch_rev_id); } // check if the branch had children list($output) = $repository_api->execxLocal( 'log -r %s --template %s', hgsprintf('children(%s)', $this->branch), '{node}\n'); $child_branch_roots = phutil_split_lines($output, false); $child_branch_roots = array_filter($child_branch_roots); if ($child_branch_roots) { // move the branch's children onto the collapsed commit foreach ($child_branch_roots as $child_root) { $repository_api->execxLocal( 'rebase -d %s -s %s --keep --keepbranches', $this->onto, $child_root); } } // All the rebases may have moved us to another branch // so we move back. $repository_api->execxLocal('checkout %s', $this->onto); } } /** * Detect alternate branches and prompt the user for how to handle * them. An alternate branch is a branch that forks from the landing * branch prior to the landing branch tip. * * In a situation like this: * -a--------b master * \ * w--x landingbranch * \ \-- g subbranch * \--y altbranch1 * \--z altbranch2 * * y and z are alternate branches and will get deleted by the squash, * so we need to detect them and ask the user what they want to do. * * @param string The revision id of the landing branch's root commit. * @param string The revset specifying all the commits in the landing branch. * @return void */ private function handleAlternateBranches($branch_root, $branch_range) { $repository_api = $this->getRepositoryAPI(); // Using the tree in the doccomment, the revset below resolves as follows: // 1. roots(descendants(w) - descendants(x) - (w::x)) // 2. roots({x,g,y,z} - {g} - {w,x}) // 3. roots({y,z}) // 4. {y,z} $alt_branch_revset = hgsprintf( 'roots(descendants(%s)-descendants(%s)-%R)', $branch_root, $this->branch, $branch_range); list($alt_branches) = $repository_api->execxLocal( 'log --template %s -r %s', '{node}\n', $alt_branch_revset); $alt_branches = phutil_split_lines($alt_branches, false); $alt_branches = array_filter($alt_branches); $alt_count = count($alt_branches); if ($alt_count > 0) { $input = phutil_console_prompt(pht( "%s '%s' has %s %s(s) forking off of it that would be deleted ". "during a squash. Would you like to keep a non-squashed copy, rebase ". "them on top of '%s', or abort and deal with them yourself? ". "(k)eep, (r)ebase, (a)bort:", ucfirst($this->branchType), $this->branch, $alt_count, $this->branchType, $this->branch)); if ($input == 'k' || $input == 'keep') { $this->keepBranch = true; } else if ($input == 'r' || $input == 'rebase') { foreach ($alt_branches as $alt_branch) { $repository_api->execxLocal( 'rebase --keep --keepbranches -d %s -s %s', $this->branch, $alt_branch); } } else if ($input == 'a' || $input == 'abort') { $branch_string = implode("\n", $alt_branches); echo "\n", pht( "Remove the %s starting at these revisions and run %s again:\n%s", $this->branchType.'s', $branch_string, 'arc land'), "\n\n"; throw new ArcanistUserAbortException(); } else { throw new ArcanistUsageException( pht('Invalid choice. Aborting arc land.')); } } } private function merge() { $repository_api = $this->getRepositoryAPI(); // In immutable histories, do a --no-ff merge to force a merge commit with // the right message. $repository_api->execxLocal('checkout %s', $this->onto); chdir($repository_api->getPath()); if ($this->isGit) { $err = phutil_passthru( 'git merge --no-stat --no-ff --no-commit %s', $this->branch); if ($err) { throw new ArcanistUsageException(pht( "'%s' failed. Your working copy has been left in a partially ". "merged state. You can: abort with '%s'; or follow the ". "instructions to complete the merge.", 'git merge', 'git merge --abort')); } } else if ($this->isHg) { // HG arc land currently doesn't support --merge. // When merging a bookmark branch to a master branch that // hasn't changed since the fork, mercurial fails to merge. // Instead of only working in some cases, we just disable --merge // until there is a demand for it. // The user should never reach this line, since --merge is // forbidden at the command line argument level. throw new ArcanistUsageException( pht('%s is not currently supported for hg repos.', '--merge')); } } private function push() { $repository_api = $this->getRepositoryAPI(); // These commands can fail legitimately (e.g. commit hooks) try { if ($this->isGit) { $repository_api->execxLocal('commit -F %s', $this->messageFile); if (phutil_is_windows()) { // Occasionally on large repositories on Windows, Git can exit with // an unclean working copy here. This prevents reverts from being // pushed to the remote when this occurs. $this->requireCleanWorkingCopy(); } } else if ($this->isHg) { // hg rebase produces a commit earlier as part of rebase if (!$this->useSquash) { $repository_api->execxLocal( 'commit --logfile %s', $this->messageFile); } } // We dispatch this event so we can run checks on the merged revision, // right before it gets pushed out. It's easier to do this in arc land // than to try to hook into git/hg. $this->didCommitMerge(); } catch (Exception $ex) { $this->executeCleanupAfterFailedPush(); throw $ex; } if ($this->getArgument('hold')) { echo phutil_console_format(pht( 'Holding change in **%s**: it has NOT been pushed yet.', $this->onto)."\n"); } else { echo pht('Pushing change...'), "\n\n"; chdir($repository_api->getPath()); if ($this->isGitSvn) { $err = phutil_passthru('git svn dcommit'); $cmd = 'git svn dcommit'; } else if ($this->isGit) { $err = phutil_passthru('git push %s %s', $this->remote, $this->onto); $cmd = 'git push'; } else if ($this->isHgSvn) { // hg-svn doesn't support 'push -r', so we do a normal push // which hg-svn modifies to only push the current branch and // ancestors. $err = $repository_api->execPassthru('push %s', $this->remote); $cmd = 'hg push'; } else if ($this->isHg) { if (strlen($this->remote)) { $err = $repository_api->execPassthru( 'push -r %s %s', $this->onto, $this->remote); } else { $err = $repository_api->execPassthru( 'push -r %s', $this->onto); } $cmd = 'hg push'; } if ($err) { echo phutil_console_format( "** %s **\n", pht('PUSH FAILED!')); $this->executeCleanupAfterFailedPush(); if ($this->isGit) { throw new ArcanistUsageException(pht( "'%s' failed! Fix the error and run '%s' again.", $cmd, 'arc land')); } throw new ArcanistUsageException(pht( "'%s' failed! Fix the error and push this change manually.", $cmd)); } $this->didPush(); echo "\n"; } } private function executeCleanupAfterFailedPush() { $repository_api = $this->getRepositoryAPI(); if ($this->isGit) { $repository_api->execxLocal('reset --hard HEAD^'); $this->restoreBranch(); } else if ($this->isHg) { $repository_api->execxLocal( '--config extensions.mq= strip %s', $this->onto); $this->restoreBranch(); } } private function cleanupBranch() { $repository_api = $this->getRepositoryAPI(); echo pht('Cleaning up feature %s...', $this->branchType), "\n"; if ($this->isGit) { list($ref) = $repository_api->execxLocal( 'rev-parse --verify %s', $this->branch); $ref = trim($ref); $recovery_command = csprintf( 'git checkout -b %s %s', $this->branch, $ref); echo pht('(Use `%s` if you want it back.)', $recovery_command), "\n"; $repository_api->execxLocal('branch -D %s', $this->branch); } else if ($this->isHg) { $common_ancestor = $repository_api->getCanonicalRevisionName( hgsprintf('ancestor(%s,%s)', $this->onto, $this->branch)); $branch_root = $repository_api->getCanonicalRevisionName( hgsprintf('first((%s::%s)-%s)', $common_ancestor, $this->branch, $common_ancestor)); $repository_api->execxLocal( '--config extensions.mq= strip -r %s', $branch_root); if ($repository_api->isBookmark($this->branch)) { $repository_api->execxLocal('bookmark -d %s', $this->branch); } } if ($this->getArgument('delete-remote')) { if ($this->isHg) { // named branches were closed as part of the earlier commit // so only worry about bookmarks if ($repository_api->isBookmark($this->branch)) { $repository_api->execxLocal( 'push -B %s %s', $this->branch, $this->remote); } } } } public function getSupportedRevisionControlSystems() { return array('git', 'hg'); } private function getBranchOrBookmark() { $repository_api = $this->getRepositoryAPI(); if ($this->isGit) { $branch = $repository_api->getBranchName(); // If we don't have a branch name, just use whatever's at HEAD. if (!strlen($branch) && !$this->isGitSvn) { $branch = $repository_api->getWorkingCopyRevision(); } } else if ($this->isHg) { $branch = $repository_api->getActiveBookmark(); if (!$branch) { $branch = $repository_api->getBranchName(); } } return $branch; } private function getBranchType($branch) { $repository_api = $this->getRepositoryAPI(); if ($this->isHg && $repository_api->isBookmark($branch)) { return 'bookmark'; } return 'branch'; } /** * Restore the original branch, e.g. after a successful land or a failed * pull. */ private function restoreBranch() { $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal('checkout %s', $this->oldBranch); if ($this->isGit) { $repository_api->execxLocal('submodule update --init --recursive'); } echo pht( "Switched back to %s %s.\n", $this->branchType, phutil_console_format('**%s**', $this->oldBranch)); } /** * Check if a diff has a running or failed buildable, and prompt the user * before landing if it does. */ private function checkForBuildables($diff_phid) { // Try to use the more modern check which respects the "Warn on Land" // behavioral flag on build plans if we can. This newer check won't work // unless the server is running code from March 2019 or newer since the // API methods we need won't exist yet. We'll fall back to the older check // if this one doesn't work out. try { $this->checkForBuildablesWithPlanBehaviors($diff_phid); + return; } catch (ArcanistUserAbortException $abort_ex) { throw $abort_ex; } catch (Exception $ex) { // Continue with the older approach, below. } // NOTE: Since Harbormaster is still beta and this stuff all got added // recently, just bail if we can't find a buildable. This is just an // advisory check intended to prevent human error. try { $buildables = $this->getConduit()->callMethodSynchronous( 'harbormaster.querybuildables', array( 'buildablePHIDs' => array($diff_phid), 'manualBuildables' => false, )); } catch (ConduitClientException $ex) { return; } if (!$buildables['data']) { // If there's no corresponding buildable, we're done. return; } $console = PhutilConsole::getConsole(); $buildable = head($buildables['data']); if ($buildable['buildableStatus'] == 'passed') { $console->writeOut( "** %s ** %s\n", pht('BUILDS PASSED'), pht('Harbormaster builds for the active diff completed successfully.')); return; } switch ($buildable['buildableStatus']) { case 'building': $message = pht( 'Harbormaster is still building the active diff for this revision.'); $prompt = pht('Land revision anyway, despite ongoing build?'); break; case 'failed': $message = pht( 'Harbormaster failed to build the active diff for this revision.'); $prompt = pht('Land revision anyway, despite build failures?'); break; default: // If we don't recognize the status, just bail. return; } $builds = $this->queryBuilds( array( 'buildablePHIDs' => array($buildable['phid']), )); $console->writeOut($message."\n\n"); $builds = msort($builds, 'getStatusSortVector'); foreach ($builds as $build) { $ansi_color = $build->getStatusANSIColor(); $status_name = $build->getStatusName(); $object_name = $build->getObjectName(); $build_name = $build->getName(); echo tsprintf( " ** %s ** %s: %s\n", $status_name, $object_name, $build_name); } $console->writeOut( "\n%s\n\n **%s**: __%s__", pht('You can review build details here:'), pht('Harbormaster URI'), $buildable['uri']); if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } private function checkForBuildablesWithPlanBehaviors($diff_phid) { // TODO: These queries should page through all results instead of fetching // only the first page, but we don't have good primitives to support that // in "master" yet. $this->writeInfo( pht('BUILDS'), pht('Checking build status...')); $raw_buildables = $this->getConduit()->callMethodSynchronous( 'harbormaster.buildable.search', array( 'constraints' => array( 'objectPHIDs' => array( $diff_phid, ), 'manual' => false, ), )); if (!$raw_buildables['data']) { return; } $buildables = $raw_buildables['data']; $buildable_phids = ipull($buildables, 'phid'); $raw_builds = $this->getConduit()->callMethodSynchronous( 'harbormaster.build.search', array( 'constraints' => array( 'buildables' => $buildable_phids, ), )); if (!$raw_builds['data']) { return; } $builds = array(); foreach ($raw_builds['data'] as $raw_build) { $build_ref = ArcanistBuildRef::newFromConduit($raw_build); $build_phid = $build_ref->getPHID(); $builds[$build_phid] = $build_ref; } $plan_phids = mpull($builds, 'getBuildPlanPHID'); $plan_phids = array_values($plan_phids); $raw_plans = $this->getConduit()->callMethodSynchronous( 'harbormaster.buildplan.search', array( 'constraints' => array( 'phids' => $plan_phids, ), )); $plans = array(); foreach ($raw_plans['data'] as $raw_plan) { $plan_ref = ArcanistBuildPlanRef::newFromConduit($raw_plan); $plan_phid = $plan_ref->getPHID(); $plans[$plan_phid] = $plan_ref; } $ongoing_builds = array(); $failed_builds = array(); $builds = msort($builds, 'getStatusSortVector'); foreach ($builds as $build_ref) { $plan = idx($plans, $build_ref->getBuildPlanPHID()); if (!$plan) { continue; } $plan_behavior = $plan->getBehavior('arc-land', 'always'); $if_building = ($plan_behavior == 'building'); $if_complete = ($plan_behavior == 'complete'); $if_never = ($plan_behavior == 'never'); // If the build plan "Never" warns when landing, skip it. if ($if_never) { continue; } // If the build plan warns when landing "If Complete" but the build is // not complete, skip it. if ($if_complete && !$build_ref->isComplete()) { continue; } // If the build plan warns when landing "If Building" but the build is // complete, skip it. if ($if_building && $build_ref->isComplete()) { continue; } // Ignore passing builds. if ($build_ref->isPassed()) { continue; } if (!$build_ref->isComplete()) { $ongoing_builds[] = $build_ref; } else { $failed_builds[] = $build_ref; } } if (!$ongoing_builds && !$failed_builds) { return; } if ($failed_builds) { $this->writeWarn( pht('BUILD FAILURES'), pht( 'Harbormaster failed to build the active diff for this revision:')); $prompt = pht('Land revision anyway, despite build failures?'); } else if ($ongoing_builds) { $this->writeWarn( pht('ONGOING BUILDS'), pht( 'Harbormaster is still building the active diff for this revision:')); $prompt = pht('Land revision anyway, despite ongoing build?'); } $show_builds = array_merge($failed_builds, $ongoing_builds); echo "\n"; foreach ($show_builds as $build_ref) { $ansi_color = $build_ref->getStatusANSIColor(); $status_name = $build_ref->getStatusName(); $object_name = $build_ref->getObjectName(); $build_name = $build_ref->getName(); echo tsprintf( " ** %s ** %s: %s\n", $status_name, $object_name, $build_name); } echo tsprintf( "\n%s\n\n", pht('You can review build details here:')); foreach ($buildables as $buildable) { $buildable_uri = id(new PhutilURI($this->getConduitURI())) ->setPath(sprintf('/B%d', $buildable['id'])); echo tsprintf( " **%s**: __%s__\n", pht('Buildable %d', $buildable['id']), $buildable_uri); } if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } public function buildEngineMessage(ArcanistLandEngine $engine) { // TODO: This is oh-so-gross. $this->findRevision(); $engine->setCommitMessageFile($this->messageFile); } public function didCommitMerge() { $this->dispatchEvent( ArcanistEventType::TYPE_LAND_WILLPUSHREVISION, array()); } public function didPush() { $this->askForRepositoryUpdate(); $mark_workflow = $this->buildChildWorkflow( 'close-revision', array( '--finalize', '--quiet', $this->revision['id'], )); $mark_workflow->run(); } private function queryBuilds(array $constraints) { $conduit = $this->getConduit(); // NOTE: This method only loads the 100 most recent builds. It's rare for // a revision to have more builds than that and there's currently no paging // wrapper for "*.search" Conduit API calls available in Arcanist. try { $raw_result = $conduit->callMethodSynchronous( 'harbormaster.build.search', array( 'constraints' => $constraints, )); } catch (Exception $ex) { // If the server doesn't have "harbormaster.build.search" yet (Aug 2016), // try the older "harbormaster.querybuilds" instead. $raw_result = $conduit->callMethodSynchronous( 'harbormaster.querybuilds', $constraints); } $refs = array(); foreach ($raw_result['data'] as $raw_data) { $refs[] = ArcanistBuildRef::newFromConduit($raw_data); } return $refs; } }