diff --git a/src/applications/differential/conduit/DifferentialParseCommitMessageConduitAPIMethod.php b/src/applications/differential/conduit/DifferentialParseCommitMessageConduitAPIMethod.php index 107d22d2a9..4527983311 100644 --- a/src/applications/differential/conduit/DifferentialParseCommitMessageConduitAPIMethod.php +++ b/src/applications/differential/conduit/DifferentialParseCommitMessageConduitAPIMethod.php @@ -1,142 +1,105 @@ 'required string', 'partial' => 'optional bool', ); } protected function defineReturnType() { return 'nonempty dict'; } protected function execute(ConduitAPIRequest $request) { $viewer = $request->getUser(); $corpus = $request->getValue('corpus'); $is_partial = $request->getValue('partial'); - $revision = new DifferentialRevision(); - $field_list = PhabricatorCustomField::getObjectFields( - $revision, + new DifferentialRevision(), DifferentialCustomField::ROLE_COMMITMESSAGE); $field_list->setViewer($viewer); - $field_map = mpull($field_list->getFields(), null, 'getFieldKeyForConduit'); - - $this->errors = array(); + $field_map = mpull($field_list, null, 'getFieldKeyForConduit'); - $label_map = $this->buildLabelMap($field_list); - $corpus_map = $this->parseCommitMessage($corpus, $label_map); + $corpus_map = $this->parseCommitMessage($corpus); $values = array(); foreach ($corpus_map as $field_key => $text_value) { $field = idx($field_map, $field_key); if (!$field) { throw new Exception( pht( 'Parser emitted text value for field key "%s", but no such '. 'field exists.', $field_key)); } try { $values[$field_key] = $field->parseValueFromCommitMessage($text_value); } catch (DifferentialFieldParseException $ex) { $this->errors[] = pht( 'Error parsing field "%s": %s', $field->renderCommitMessageLabel(), $ex->getMessage()); } } if (!$is_partial) { foreach ($field_map as $key => $field) { try { $field->validateCommitMessageValue(idx($values, $key)); } catch (DifferentialFieldValidationException $ex) { $this->errors[] = pht( 'Invalid or missing field "%s": %s', $field->renderCommitMessageLabel(), $ex->getMessage()); } } } // grab some extra information about the Differential Revision: field... $revision_id_field = new DifferentialRevisionIDField(); $revision_id_value = idx( $corpus_map, $revision_id_field->getFieldKeyForConduit()); $revision_id_valid_domain = PhabricatorEnv::getProductionURI(''); return array( 'errors' => $this->errors, 'fields' => $values, 'revisionIDFieldInfo' => array( 'value' => $revision_id_value, 'validDomain' => $revision_id_valid_domain, ), ); } - private function buildLabelMap(PhabricatorCustomFieldList $field_list) { - $label_map = array(); - - foreach ($field_list->getFields() as $key => $field) { - $labels = $field->getCommitMessageLabels(); - $key = $field->getFieldKeyForConduit(); - - foreach ($labels as $label) { - $normal_label = DifferentialCommitMessageParser::normalizeFieldLabel( - $label); - if (!empty($label_map[$normal_label])) { - throw new Exception( - pht( - 'Field label "%s" is parsed by two custom fields: "%s" and '. - '"%s". Each label must be parsed by only one field.', - $label, - $key, - $label_map[$normal_label])); - } - $label_map[$normal_label] = $key; - } - } - - return $label_map; - } - - - private function parseCommitMessage($corpus, array $label_map) { - $key_title = id(new DifferentialTitleField())->getFieldKeyForConduit(); - $key_summary = id(new DifferentialSummaryField())->getFieldKeyForConduit(); - - $parser = id(new DifferentialCommitMessageParser()) - ->setLabelMap($label_map) - ->setTitleKey($key_title) - ->setSummaryKey($key_summary); - + private function parseCommitMessage($corpus) { + $viewer = $this->getViewer(); + $parser = DifferentialCommitMessageParser::newStandardParser($viewer); $result = $parser->parseCorpus($corpus); + $this->errors = array(); foreach ($parser->getErrors() as $error) { $this->errors[] = $error; } return $result; } } diff --git a/src/applications/differential/customfield/DifferentialCoreCustomField.php b/src/applications/differential/customfield/DifferentialCoreCustomField.php index 87d18553ad..7b6d02276d 100644 --- a/src/applications/differential/customfield/DifferentialCoreCustomField.php +++ b/src/applications/differential/customfield/DifferentialCoreCustomField.php @@ -1,133 +1,176 @@ setFieldError(null); $errors = parent::validateApplicationTransactions( $editor, $type, $xactions); $transaction = null; foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); if ($this->isCoreFieldRequired()) { if ($this->isCoreFieldValueEmpty($value)) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), $this->getCoreFieldRequiredErrorString(), $xaction); $error->setIsMissingFieldError(true); $errors[] = $error; $this->setFieldError(pht('Required')); + continue; + } + } + + if (is_string($value)) { + $parser = $this->getFieldParser(); + $result = $parser->parseCorpus($value); + + unset($result['__title__']); + unset($result['__summary__']); + + if ($result) { + $error = new PhabricatorApplicationTransactionValidationError( + $type, + pht('Invalid'), + pht( + 'The value you have entered in "%s" can not be parsed '. + 'unambiguously when rendered in a commit message. Edit the '. + 'message so that keywords like "Summary:" and "Test Plan:" do '. + 'not appear at the beginning of lines. Parsed keys: %s.', + $this->getFieldName(), + implode(', ', array_keys($result))), + $xaction); + $errors[] = $error; + $this->setFieldError(pht('Invalid')); + continue; } } } return $errors; } + private function getFieldParser() { + if (!$this->fieldParser) { + $viewer = $this->getViewer(); + $parser = DifferentialCommitMessageParser::newStandardParser($viewer); + + // Set custom title and summary keys so we can detect the presence of + // "Summary:" in, e.g., a test plan. + $parser->setTitleKey('__title__'); + $parser->setSummaryKey('__summary__'); + + $this->fieldParser = $parser; + } + + return $this->fieldParser; + } + public function canDisableField() { return false; } public function shouldAppearInApplicationTransactions() { return true; } public function shouldAppearInEditView() { return true; } public function readValueFromObject(PhabricatorCustomFieldInterface $object) { if ($this->isCoreFieldRequired()) { $this->setFieldError(true); } $this->setValue($this->readValueFromRevision($object)); } public function getOldValueForApplicationTransactions() { return $this->readValueFromRevision($this->getObject()); } public function getNewValueForApplicationTransactions() { return $this->getValue(); } public function applyApplicationTransactionInternalEffects( PhabricatorApplicationTransaction $xaction) { $this->writeValueToRevision($this->getObject(), $xaction->getNewValue()); } public function setFieldError($field_error) { $this->fieldError = $field_error; return $this; } public function getFieldError() { return $this->fieldError; } public function setValue($value) { $this->value = $value; return $this; } public function getValue() { return $this->value; } public function readValueFromCommitMessage($value) { $this->setValue($value); return $this; } public function renderCommitMessageValue(array $handles) { return $this->getValue(); } public function getConduitDictionaryValue() { return $this->getValue(); } } diff --git a/src/applications/differential/parser/DifferentialCommitMessageParser.php b/src/applications/differential/parser/DifferentialCommitMessageParser.php index fda7ae05b6..ff76b30e51 100644 --- a/src/applications/differential/parser/DifferentialCommitMessageParser.php +++ b/src/applications/differential/parser/DifferentialCommitMessageParser.php @@ -1,216 +1,255 @@ setLabelMap($label_map) * ->setTitleKey($key_title) * ->setSummaryKey($key_summary); * * $fields = $parser->parseCorpus($corpus); * $errors = $parser->getErrors(); * * This is used by Differential to parse messages entered from the command line. * * @task config Configuring the Parser * @task parse Parsing Messages * @task support Support Methods * @task internal Internals */ final class DifferentialCommitMessageParser extends Phobject { private $labelMap; private $titleKey; private $summaryKey; private $errors; + public static function newStandardParser(PhabricatorUser $viewer) { + + $key_title = id(new DifferentialTitleField())->getFieldKeyForConduit(); + $key_summary = id(new DifferentialSummaryField())->getFieldKeyForConduit(); + + $field_list = PhabricatorCustomField::getObjectFields( + new DifferentialRevision(), + DifferentialCustomField::ROLE_COMMITMESSAGE); + $field_list->setViewer($viewer); + + $label_map = array(); + + foreach ($field_list->getFields() as $field) { + $labels = $field->getCommitMessageLabels(); + $key = $field->getFieldKeyForConduit(); + + foreach ($labels as $label) { + $normal_label = self::normalizeFieldLabel( + $label); + if (!empty($label_map[$normal_label])) { + throw new Exception( + pht( + 'Field label "%s" is parsed by two custom fields: "%s" and '. + '"%s". Each label must be parsed by only one field.', + $label, + $key, + $label_map[$normal_label])); + } + $label_map[$normal_label] = $key; + } + } + + return id(new self()) + ->setLabelMap($label_map) + ->setTitleKey($key_title) + ->setSummaryKey($key_summary); + } + + /* -( Configuring the Parser )--------------------------------------------- */ /** * @task config */ public function setLabelMap(array $label_map) { $this->labelMap = $label_map; return $this; } /** * @task config */ public function setTitleKey($title_key) { $this->titleKey = $title_key; return $this; } /** * @task config */ public function setSummaryKey($summary_key) { $this->summaryKey = $summary_key; return $this; } /* -( Parsing Messages )--------------------------------------------------- */ /** * @task parse */ public function parseCorpus($corpus) { $this->errors = array(); $label_map = $this->labelMap; $key_title = $this->titleKey; $key_summary = $this->summaryKey; if (!$key_title || !$key_summary || ($label_map === null)) { throw new Exception( pht( 'Expected %s, %s and %s to be set before parsing a corpus.', 'labelMap', 'summaryKey', 'titleKey')); } $label_regexp = $this->buildLabelRegexp($label_map); // NOTE: We're special casing things here to make the "Title:" label // optional in the message. $field = $key_title; $seen = array(); $lines = explode("\n", trim($corpus)); $field_map = array(); foreach ($lines as $key => $line) { $match = null; if (preg_match($label_regexp, $line, $match)) { $lines[$key] = trim($match['text']); $field = $label_map[self::normalizeFieldLabel($match['field'])]; if (!empty($seen[$field])) { $this->errors[] = pht( 'Field "%s" occurs twice in commit message!', $field); } $seen[$field] = true; } $field_map[$key] = $field; } $fields = array(); foreach ($lines as $key => $line) { $fields[$field_map[$key]][] = $line; } // This is a piece of special-cased magic which allows you to omit the // field labels for "title" and "summary". If the user enters a large block // of text at the beginning of the commit message with an empty line in it, // treat everything before the blank line as "title" and everything after // as "summary". if (isset($fields[$key_title]) && empty($fields[$key_summary])) { $lines = $fields[$key_title]; for ($ii = 0; $ii < count($lines); $ii++) { if (strlen(trim($lines[$ii])) == 0) { break; } } if ($ii != count($lines)) { $fields[$key_title] = array_slice($lines, 0, $ii); $summary = array_slice($lines, $ii); if (strlen(trim(implode("\n", $summary)))) { $fields[$key_summary] = $summary; } } } // Implode all the lines back into chunks of text. foreach ($fields as $name => $lines) { $data = rtrim(implode("\n", $lines)); $data = ltrim($data, "\n"); $fields[$name] = $data; } // This is another piece of special-cased magic which allows you to // enter a ridiculously long title, or just type a big block of stream // of consciousness text, and have some sort of reasonable result conjured // from it. if (isset($fields[$key_title])) { $terminal = '...'; $title = $fields[$key_title]; $short = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes(250) ->setTerminator($terminal) ->truncateString($title); if ($short != $title) { // If we shortened the title, split the rest into the summary, so // we end up with a title like: // // Title title tile title title... // // ...and a summary like: // // ...title title title. // // Summary summary summary summary. $summary = idx($fields, $key_summary, ''); $offset = strlen($short) - strlen($terminal); $remainder = ltrim(substr($fields[$key_title], $offset)); $summary = '...'.$remainder."\n\n".$summary; $summary = rtrim($summary, "\n"); $fields[$key_title] = $short; $fields[$key_summary] = $summary; } } return $fields; } /** * @task parse */ public function getErrors() { return $this->errors; } /* -( Support Methods )---------------------------------------------------- */ /** * @task support */ public static function normalizeFieldLabel($label) { return phutil_utf8_strtolower($label); } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function buildLabelRegexp(array $label_map) { $field_labels = array_keys($label_map); foreach ($field_labels as $key => $label) { $field_labels[$key] = preg_quote($label, '/'); } $field_labels = implode('|', $field_labels); $field_pattern = '/^(?P'.$field_labels.'):(?P.*)$/i'; return $field_pattern; } }