diff --git a/differential_extension/DifferentialGetInlineCommentsConduitAPIMethod.php b/differential_extension/DifferentialGetInlineCommentsConduitAPIMethod.php new file mode 100644 --- /dev/null +++ b/differential_extension/DifferentialGetInlineCommentsConduitAPIMethod.php @@ -0,0 +1,107 @@ + 'required int', + ); + } + + protected function defineReturnType() { + return 'wild'; // nonempty dict>'; + } + + protected function execute(ConduitAPIRequest $request) { + $viewer = $request->getUser(); + $revision_id = $request->getValue('id'); + + if (!$revision_id) { + return array(); + } + + $revisions = id(new DifferentialRevisionQuery()) + ->setViewer($viewer) + ->withIDs(array($revision_id)) + ->execute(); + + $xactions = id(new DifferentialTransactionQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(mpull($revisions, 'getPHID')) + ->execute(); + + $comments = array(); + foreach ($xactions as $xact) { + if ($xact->isInlineCommentTransaction()) { + $comments[] = $xact->getComment(); + } + } + + $changeset_ids = array_unique(mpull($comments, 'getChangesetID')); + + $changesets = id(new DifferentialChangesetQuery()) + ->setViewer($viewer) + ->withIDs($changeset_ids) + ->needHunks(true) + ->execute(); + + $ret = array(); + foreach ($comments as $comment) { + if ($comment->getIsDeleted()) { + continue; + } + + $changeset = idx($changesets, $comment->getChangesetID()); + if (!$changeset) { + continue; + } + + $file = $changeset->makeNewFile(); + $commented_text = null; + if ($file) { + $commented_lines = array_slice( + explode("\n", $file), + $comment->getLineNumber() - 1, + $comment->getLineLength() + 1); + + $commented_text = implode("\n", $commented_lines)."\n"; + } + + $diff_id = $changeset->getDiffID(); + $ret[$diff_id][] = array( + 'phid' => $comment->getTransactionPHID(), + 'authorPHID' => $comment->getAuthorPHID(), + 'content' => $comment->getContent(), + 'contentSource' => $comment->getContentSource() ? + $comment->getContentSource()->getSource() : null, + 'filename' => $changeset->getFilename(), + 'changeType' => DifferentialChangeType::getFullNameForChangeType( + $changeset->getChangeType()), + 'lineNumber' => $comment->getLineNumber(), + 'lineLength' => $comment->getLineLength(), + 'commentedLines' => $commented_text, + 'repliedID' => $comment->getHasReplies() ? + $comment->getReplyToCommentPHID() : null, + ); + } + + return $ret; + } +} diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -215,6 +215,7 @@ 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorReferenceXHPASTLinterRule.php', 'ArcanistReusedIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorXHPASTLinterRule.php', 'ArcanistRevertWorkflow' => 'workflow/ArcanistRevertWorkflow.php', + 'ArcanistReviseWorkflow' => 'workflow/ArcanistReviseWorkflow.php', 'ArcanistRuboCopLinter' => 'lint/linter/ArcanistRuboCopLinter.php', 'ArcanistRuboCopLinterTestCase' => 'lint/linter/__tests__/ArcanistRuboCopLinterTestCase.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', @@ -501,6 +502,7 @@ 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistRevertWorkflow' => 'ArcanistWorkflow', + 'ArcanistReviseWorkflow' => 'ArcanistWorkflow', 'ArcanistRuboCopLinter' => 'ArcanistExternalLinter', 'ArcanistRuboCopLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRubyLinter' => 'ArcanistExternalLinter', diff --git a/src/workflow/ArcanistReviseWorkflow.php b/src/workflow/ArcanistReviseWorkflow.php new file mode 100644 --- /dev/null +++ b/src/workflow/ArcanistReviseWorkflow.php @@ -0,0 +1,249 @@ +buildChildWorkflow( + 'amend', + array()); + $amend_workflow->run(); + + $repository_api = $this->getRepositoryAPI(); + $repository_api->setBaseCommitArgumentRules('arc:this'); + $in_working_copy = $repository_api->loadWorkingCopyDifferentialRevisions( + $this->getConduit(), + array( + 'status' => 'status-any', + )); + $in_working_copy = ipull($in_working_copy, null, 'id'); + + if (count($in_working_copy) == 0) { + throw new ArcanistUsageException( + pht( + 'No revisions found '. + 'in the working copy. We may soon add --revision so you can specify which revision to use') + ); + } else if (count($in_working_copy) > 1) { + $message = pht( + "More than one revision was found in the working copy. ". + "I don't know how to deal with this yet"); + throw new ArcanistUsageException($message); + } + $revision_id = key($in_working_copy); + $revision = $in_working_copy[$revision_id]; + + $conduit = $this->getConduit(); + $comments = $conduit->callMethodSynchronous( + 'differential.getinlinecomments', + array( + 'id' => $revision_id, + )); + + $results = array(); + foreach ($comments as $diff_id => $diff_comments) { + foreach ($diff_comments as $comment) { + $raw_replacement_text = $this->getCodeBlockAtBeginning($comment['content']); + if (!$raw_replacement_text) { + continue; + } + + if (!idx($results, $comment['filename'])) { + $file_path_on_disk = Filesystem::resolvePath( + $comment['filename'], + $this->getWorkingCopy()->getProjectRoot()); + + $file_data = Filesystem::readFile($file_path_on_disk); + + $new_result = id(new ArcanistLintResult()) + ->setPath($comment['filename']) + ->setFilePathOnDisk($file_path_on_disk) + ->setData($file_data); + + $results[$comment['filename']] = $new_result; + } + $result = $results[$comment['filename']]; + + $file_lines = explode("\n", $result->getData()); + + $original_lines = array_slice($file_lines, + $comment['lineNumber'] - 1, + $comment['lineLength'] + 1); + + $original_text = implode("\n", $original_lines); + + if (strpos($original_text, trim($comment['commentedLines'])) === false) { + $console->writeOut("Skipping patch written for outdated code\n"); + continue; + } + + // TODO: verify the original text that the comment author saw is the + // same as the current original text + + $replacement_text = $this->matchIndentation( + $original_text, $raw_replacement_text); + + $message = id(new ArcanistLintMessage()) + ->setPath($comment['filename']) + ->setLine($comment['lineNumber']) + ->setChar(1) + ->setCode('PATCH') + ->setName('Inline Patch') + ->setSeverity(ArcanistLintSeverity::SEVERITY_AUTOFIX) + ->setDescription('Comment on diff '.$diff_id) + ->setOriginalText($original_text) + ->setReplacementText($replacement_text); + + $results[$comment['filename']]->addMessage($message); + } + } + + // we might have created files that have no patchable comments + foreach ($results as $filename => $result) { + if (!$result->getMessages()) { + unset($results[$filename]); + } + } + + $patchers = array(); + $diffs = array(); + foreach ($results as $filename => $result) { + $patchers[$filename] = ArcanistLintPatcher::newFromArcanistLintResult( + $result); + + // run diff on the applied patches + $temp_patched_file = new TempFile(); + Filesystem::writeFile( + $temp_patched_file, + $patchers[$filename]->getModifiedFileContent()); + + list(, $stdout, $stderr) = exec_manual( + 'diff -u %s %s --label %s --label %s', + $result->getFilePathOnDisk(), + $temp_patched_file, + $result->getPath(), + 'Patched'); + + $diffs[] = $stdout; + } + + $console->writeOut('%s', implode("\n\n", $diffs)); + + $prompt = pht('Apply patches?'); + + if (!$console->confirm($prompt, $default = true)) { + return; + } + + foreach ($patchers as $patcher) { + $patcher->writePatchToDisk(); + } + + $console->writeOut("Done.\n"); + + return 0; + } + + public function getCodeBlockAtBeginning($content) { + $lines = explode("\n", $content); + + $cursor = 0; + $code_block_rule = new PhutilRemarkupCodeBlockRule(); + $line_count = $code_block_rule->getMatchingLineCount($lines, $cursor); + + if ($line_count === 0) { + return null; + } + + $replacement_lines = array(); + for ($i = 0; $i < $line_count; $i++) { + // if a line only consists of a ```, we want to skip it. + // even if it doesn't, we still want to delete all ```, + // since a single line could have two of these. + if (trim($lines[$i]) !== '```') { + $replacement_lines[] = str_replace('```', '', $lines[$i]); + } + } + + return implode("\n", $replacement_lines); + } + + public function matchIndentation($original, $replacement) { + // TODO: assumes no line is less indented than the first (bad things happen + // right now if that's not true). Probably the right behavior is to treat + // all indents <= the first line indent at 0 (relative to the original + // indent) + $old_lines = explode("\n", $original); + $new_lines = explode("\n", $replacement); + + // first, set the replacement text to a base indent of 0. + $new_indent = strlen($new_lines[0]) - strlen(ltrim($new_lines[0])); + $updated_new_lines = array(); + foreach ($new_lines as $line) { + $updated_new_lines[] = substr($line, $new_indent); + } + $new_lines = $updated_new_lines; + + // now, add the indent of the original text + $old_indent = strlen($old_lines[0]) - strlen(ltrim($old_lines[0])); + + $updated_new_lines = array(); + foreach ($new_lines as $idx => $line) { + $updated_new_lines[] = str_repeat(' ', $old_indent).$line; + } + $new_lines = $updated_new_lines; + + return implode("\n", $new_lines); + } + + public function getSupportedRevisionControlSystems() { + return array('git', 'hg'); + } +}