diff --git a/resources/sql/autopatches/20150616.divinerrepository.sql b/resources/sql/autopatches/20150616.divinerrepository.sql new file mode 100644 index 0000000000..8e6875c7d9 --- /dev/null +++ b/resources/sql/autopatches/20150616.divinerrepository.sql @@ -0,0 +1,5 @@ +ALTER TABLE {$NAMESPACE}_diviner.diviner_livebook + ADD COLUMN repositoryPHID VARBINARY(64) AFTER name; + +ALTER TABLE {$NAMESPACE}_diviner.diviner_livesymbol + ADD COLUMN repositoryPHID VARBINARY(64) AFTER bookPHID; diff --git a/src/applications/diviner/controller/DivinerAtomController.php b/src/applications/diviner/controller/DivinerAtomController.php index 8cfeb8e42a..f5b5b6d9e1 100644 --- a/src/applications/diviner/controller/DivinerAtomController.php +++ b/src/applications/diviner/controller/DivinerAtomController.php @@ -1,684 +1,694 @@ getUser(); $book_name = $request->getURIData('book'); $atom_type = $request->getURIData('type'); $atom_name = $request->getURIData('name'); $atom_context = nonempty($request->getURIData('context'), null); $atom_index = nonempty($request->getURIData('index'), null); require_celerity_resource('diviner-shared-css'); $book = id(new DivinerBookQuery()) ->setViewer($viewer) ->withNames(array($book_name)) ->executeOne(); if (!$book) { return new Aphront404Response(); } $symbol = id(new DivinerAtomQuery()) ->setViewer($viewer) ->withBookPHIDs(array($book->getPHID())) ->withTypes(array($atom_type)) ->withNames(array($atom_name)) ->withContexts(array($atom_context)) ->withIndexes(array($atom_index)) ->withIsDocumentable(true) ->needAtoms(true) ->needExtends(true) ->needChildren(true) ->executeOne(); if (!$symbol) { return new Aphront404Response(); } $atom = $symbol->getAtom(); $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); $crumbs->addTextCrumb( $book->getShortTitle(), '/book/'.$book->getName().'/'); $atom_short_title = $atom ? $atom->getDocblockMetaValue('short', $symbol->getTitle()) : $symbol->getTitle(); $crumbs->addTextCrumb($atom_short_title); $header = id(new PHUIHeaderView()) ->setHeader($this->renderFullSignature($symbol)) ->addTag( id(new PHUITagView()) ->setType(PHUITagView::TYPE_STATE) ->setBackgroundColor(PHUITagView::COLOR_BLUE) ->setName(DivinerAtom::getAtomTypeNameString( $atom ? $atom->getType() : $symbol->getType()))); $properties = new PHUIPropertyListView(); $group = $atom ? $atom->getProperty('group') : $symbol->getGroupName(); if ($group) { $group_name = $book->getGroupName($group); } else { $group_name = null; } $document = id(new PHUIDocumentView()) ->setBook($book->getTitle(), $group_name) ->setHeader($header) ->addClass('diviner-view') ->setFontKit(PHUIDocumentView::FONT_SOURCE_SANS) ->appendChild($properties); if ($atom) { $this->buildDefined($properties, $symbol); $this->buildExtendsAndImplements($properties, $symbol); + $this->buildRepository($properties, $symbol); $warnings = $atom->getWarnings(); if ($warnings) { $warnings = id(new PHUIInfoView()) ->setErrors($warnings) ->setTitle(pht('Documentation Warnings')) ->setSeverity(PHUIInfoView::SEVERITY_WARNING); } $document->appendChild($warnings); } $methods = $this->composeMethods($symbol); $field = 'default'; $engine = id(new PhabricatorMarkupEngine()) ->setViewer($viewer) ->addObject($symbol, $field); foreach ($methods as $method) { foreach ($method['atoms'] as $matom) { $engine->addObject($matom, $field); } } $engine->process(); if ($atom) { $content = $this->renderDocumentationText($symbol, $engine); $document->appendChild($content); } $toc = $engine->getEngineMetadata( $symbol, $field, PhutilRemarkupHeaderBlockRule::KEY_HEADER_TOC, array()); if (!$atom) { $document->appendChild( id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->appendChild(pht('This atom no longer exists.'))); } if ($atom) { $document->appendChild($this->buildParametersAndReturn(array($symbol))); } if ($methods) { $tasks = $this->composeTasks($symbol); if ($tasks) { $methods_by_task = igroup($methods, 'task'); // Add phantom tasks for methods which have a "@task" name that isn't // documented anywhere, or methods that have no "@task" name. foreach ($methods_by_task as $task => $ignored) { if (empty($tasks[$task])) { $tasks[$task] = array( 'name' => $task, 'title' => $task ? $task : pht('Other Methods'), 'defined' => $symbol, ); } } $section = id(new DivinerSectionView()) ->setHeader(pht('Tasks')); foreach ($tasks as $spec) { $section->addContent( id(new PHUIHeaderView()) ->setNoBackground(true) ->setHeader($spec['title'])); $task_methods = idx($methods_by_task, $spec['name'], array()); $inner_box = id(new PHUIBoxView()) ->addPadding(PHUI::PADDING_LARGE_LEFT) ->addPadding(PHUI::PADDING_LARGE_RIGHT) ->addPadding(PHUI::PADDING_LARGE_BOTTOM); $box_content = array(); if ($task_methods) { $list_items = array(); foreach ($task_methods as $task_method) { $atom = last($task_method['atoms']); $item = $this->renderFullSignature($atom, true); if (strlen($atom->getSummary())) { $item = array( $item, " \xE2\x80\x94 ", $atom->getSummary(), ); } $list_items[] = phutil_tag('li', array(), $item); } $box_content[] = phutil_tag( 'ul', array( 'class' => 'diviner-list', ), $list_items); } else { $no_methods = pht('No methods for this task.'); $box_content = phutil_tag('em', array(), $no_methods); } $inner_box->appendChild($box_content); $section->addContent($inner_box); } $document->appendChild($section); } $section = id(new DivinerSectionView()) ->setHeader(pht('Methods')); foreach ($methods as $spec) { $matom = last($spec['atoms']); $method_header = id(new PHUIHeaderView()) ->setNoBackground(true); $inherited = $spec['inherited']; if ($inherited) { $method_header->addTag( id(new PHUITagView()) ->setType(PHUITagView::TYPE_STATE) ->setBackgroundColor(PHUITagView::COLOR_GREY) ->setName(pht('Inherited'))); } $method_header->setHeader($this->renderFullSignature($matom)); $section->addContent( array( $method_header, $this->renderMethodDocumentationText($symbol, $spec, $engine), $this->buildParametersAndReturn($spec['atoms']), )); } $document->appendChild($section); } if ($toc) { $side = new PHUIListView(); $side->addMenuItem( id(new PHUIListItemView()) ->setName(pht('Contents')) ->setType(PHUIListItemView::TYPE_LABEL)); foreach ($toc as $key => $entry) { $side->addMenuItem( id(new PHUIListItemView()) ->setName($entry[1]) ->setHref('#'.$key)); } $document->setSideNav($side, PHUIDocumentView::NAV_TOP); } return $this->buildApplicationPage( array( $crumbs, $document, ), array( 'title' => $symbol->getTitle(), )); } private function buildExtendsAndImplements( PHUIPropertyListView $view, DivinerLiveSymbol $symbol) { $lineage = $this->getExtendsLineage($symbol); if ($lineage) { $tags = array(); foreach ($lineage as $item) { $tags[] = $this->renderAtomTag($item); } $caret = phutil_tag('span', array('class' => 'caret-right msl msr')); $tags = phutil_implode_html($caret, $tags); $view->addProperty(pht('Extends'), $tags); } $implements = $this->getImplementsLineage($symbol); if ($implements) { $items = array(); foreach ($implements as $spec) { $via = $spec['via']; $iface = $spec['interface']; if ($via == $symbol) { $items[] = $this->renderAtomTag($iface); } else { $items[] = array( $this->renderAtomTag($iface), " \xE2\x97\x80 ", $this->renderAtomTag($via), ); } } $view->addProperty( pht('Implements'), phutil_implode_html(phutil_tag('br'), $items)); } } + private function buildRepository( + PHUIPropertyListView $view, + DivinerLiveSymbol $symbol) { + + $view->addProperty( + pht('Repository'), + $this->getViewer()->renderHandle($symbol->getRepositoryPHID())); + } + private function renderAtomTag(DivinerLiveSymbol $symbol) { return id(new PHUITagView()) ->setType(PHUITagView::TYPE_OBJECT) ->setName($symbol->getName()) ->setHref($symbol->getURI()); } private function getExtendsLineage(DivinerLiveSymbol $symbol) { foreach ($symbol->getExtends() as $extends) { if ($extends->getType() == 'class') { $lineage = $this->getExtendsLineage($extends); $lineage[] = $extends; return $lineage; } } return array(); } private function getImplementsLineage(DivinerLiveSymbol $symbol) { $implements = array(); // Do these first so we get interfaces ordered from most to least specific. foreach ($symbol->getExtends() as $extends) { if ($extends->getType() == 'interface') { $implements[$extends->getName()] = array( 'interface' => $extends, 'via' => $symbol, ); } } // Now do parent interfaces. foreach ($symbol->getExtends() as $extends) { if ($extends->getType() == 'class') { $implements += $this->getImplementsLineage($extends); } } return $implements; } private function buildDefined( PHUIPropertyListView $view, DivinerLiveSymbol $symbol) { $atom = $symbol->getAtom(); $defined = $atom->getFile().':'.$atom->getLine(); $link = $symbol->getBook()->getConfig('uri.source'); if ($link) { $link = strtr( $link, array( '%%' => '%', '%f' => phutil_escape_uri($atom->getFile()), '%l' => phutil_escape_uri($atom->getLine()), )); $defined = phutil_tag( 'a', array( 'href' => $link, 'target' => '_blank', ), $defined); } $view->addProperty(pht('Defined'), $defined); } private function composeMethods(DivinerLiveSymbol $symbol) { $methods = $this->findMethods($symbol); if (!$methods) { return $methods; } foreach ($methods as $name => $method) { // Check for "@task" on each parent, to find the most recently declared // "@task". $task = null; foreach ($method['atoms'] as $key => $method_symbol) { $atom = $method_symbol->getAtom(); if ($atom->getDocblockMetaValue('task')) { $task = $atom->getDocblockMetaValue('task'); } } $methods[$name]['task'] = $task; // Set 'inherited' if this atom has no implementation of the method. if (last($method['implementations']) !== $symbol) { $methods[$name]['inherited'] = true; } else { $methods[$name]['inherited'] = false; } } return $methods; } private function findMethods(DivinerLiveSymbol $symbol) { $child_specs = array(); foreach ($symbol->getExtends() as $extends) { if ($extends->getType() == DivinerAtom::TYPE_CLASS) { $child_specs = $this->findMethods($extends); } } foreach ($symbol->getChildren() as $child) { if ($child->getType() == DivinerAtom::TYPE_METHOD) { $name = $child->getName(); if (isset($child_specs[$name])) { $child_specs[$name]['atoms'][] = $child; $child_specs[$name]['implementations'][] = $symbol; } else { $child_specs[$name] = array( 'atoms' => array($child), 'defined' => $symbol, 'implementations' => array($symbol), ); } } } return $child_specs; } private function composeTasks(DivinerLiveSymbol $symbol) { $extends_task_specs = array(); foreach ($symbol->getExtends() as $extends) { $extends_task_specs += $this->composeTasks($extends); } $task_specs = array(); $tasks = $symbol->getAtom()->getDocblockMetaValue('task'); if (strlen($tasks)) { $tasks = phutil_split_lines($tasks, $retain_endings = false); foreach ($tasks as $task) { list($name, $title) = explode(' ', $task, 2); $name = trim($name); $title = trim($title); $task_specs[$name] = array( 'name' => $name, 'title' => $title, 'defined' => $symbol, ); } } $specs = $task_specs + $extends_task_specs; // Reorder "@tasks" in original declaration order. Basically, we want to // use the documentation of the closest subclass, but put tasks which // were declared by parents first. $keys = array_keys($extends_task_specs); $specs = array_select_keys($specs, $keys) + $specs; return $specs; } private function renderFullSignature( DivinerLiveSymbol $symbol, $is_link = false) { switch ($symbol->getType()) { case DivinerAtom::TYPE_CLASS: case DivinerAtom::TYPE_INTERFACE: case DivinerAtom::TYPE_METHOD: case DivinerAtom::TYPE_FUNCTION: break; default: return $symbol->getTitle(); } $atom = $symbol->getAtom(); $out = array(); if ($atom) { if ($atom->getProperty('final')) { $out[] = 'final'; } if ($atom->getProperty('abstract')) { $out[] = 'abstract'; } if ($atom->getProperty('access')) { $out[] = $atom->getProperty('access'); } if ($atom->getProperty('static')) { $out[] = 'static'; } } switch ($symbol->getType()) { case DivinerAtom::TYPE_CLASS: case DivinerAtom::TYPE_INTERFACE: $out[] = $symbol->getType(); break; case DivinerAtom::TYPE_FUNCTION: switch ($atom->getLanguage()) { case 'php': $out[] = $symbol->getType(); break; } break; case DivinerAtom::TYPE_METHOD: switch ($atom->getLanguage()) { case 'php': $out[] = DivinerAtom::TYPE_FUNCTION; break; } break; } $anchor = null; switch ($symbol->getType()) { case DivinerAtom::TYPE_METHOD: $anchor = $symbol->getType().'/'.$symbol->getName(); break; default: break; } $out[] = phutil_tag( $anchor ? 'a' : 'span', array( 'class' => 'diviner-atom-signature-name', 'href' => $anchor ? '#'.$anchor : null, 'name' => $is_link ? null : $anchor, ), $symbol->getName()); $out = phutil_implode_html(' ', $out); if ($atom) { $parameters = $atom->getProperty('parameters'); if ($parameters !== null) { $pout = array(); foreach ($parameters as $parameter) { $pout[] = idx($parameter, 'name', '...'); } $out = array($out, '('.implode(', ', $pout).')'); } } return phutil_tag( 'span', array( 'class' => 'diviner-atom-signature', ), $out); } private function buildParametersAndReturn(array $symbols) { assert_instances_of($symbols, 'DivinerLiveSymbol'); $symbols = array_reverse($symbols); $out = array(); $collected_parameters = null; foreach ($symbols as $symbol) { $parameters = $symbol->getAtom()->getProperty('parameters'); if ($parameters !== null) { if ($collected_parameters === null) { $collected_parameters = array(); } foreach ($parameters as $key => $parameter) { if (isset($collected_parameters[$key])) { $collected_parameters[$key] += $parameter; } else { $collected_parameters[$key] = $parameter; } } } } if (nonempty($parameters)) { $out[] = id(new DivinerParameterTableView()) ->setHeader(pht('Parameters')) ->setParameters($parameters); } $collected_return = null; foreach ($symbols as $symbol) { $return = $symbol->getAtom()->getProperty('return'); if ($return) { if ($collected_return) { $collected_return += $return; } else { $collected_return = $return; } } } if (nonempty($return)) { $out[] = id(new DivinerReturnTableView()) ->setHeader(pht('Return')) ->setReturn($collected_return); } return $out; } private function renderDocumentationText( DivinerLiveSymbol $symbol, PhabricatorMarkupEngine $engine) { $field = 'default'; $content = $engine->getOutput($symbol, $field); if (strlen(trim($symbol->getMarkupText($field)))) { $content = phutil_tag( 'div', array( 'class' => 'phabricator-remarkup', ), $content); } else { $atom = $symbol->getAtom(); $content = phutil_tag( 'div', array( 'class' => 'diviner-message-not-documented', ), DivinerAtom::getThisAtomIsNotDocumentedString($atom->getType())); } return $content; } private function renderMethodDocumentationText( DivinerLiveSymbol $parent, array $spec, PhabricatorMarkupEngine $engine) { $symbols = array_values($spec['atoms']); $implementations = array_values($spec['implementations']); $field = 'default'; $out = array(); foreach ($symbols as $key => $symbol) { $impl = $implementations[$key]; if ($impl !== $parent) { if (!strlen(trim($symbol->getMarkupText($field)))) { continue; } } $doc = $this->renderDocumentationText($symbol, $engine); if (($impl !== $parent) || $out) { $where = id(new PHUIBoxView()) ->addPadding(PHUI::PADDING_MEDIUM_LEFT) ->addPadding(PHUI::PADDING_MEDIUM_RIGHT) ->addClass('diviner-method-implementation-header') ->appendChild($impl->getName()); $doc = array($where, $doc); if ($impl !== $parent) { $doc = phutil_tag( 'div', array( 'class' => 'diviner-method-implementation-inherited', ), $doc); } } $out[] = $doc; } // If we only have inherited implementations but none have documentation, // render the last one here so we get the "this thing has no documentation" // element. if (!$out) { $out[] = $this->renderDocumentationText($symbol, $engine); } return $out; } } diff --git a/src/applications/diviner/controller/DivinerBookController.php b/src/applications/diviner/controller/DivinerBookController.php index f8d222b116..a0e2f0cf51 100644 --- a/src/applications/diviner/controller/DivinerBookController.php +++ b/src/applications/diviner/controller/DivinerBookController.php @@ -1,132 +1,142 @@ getViewer(); $book_name = $request->getURIData('book'); $book = id(new DivinerBookQuery()) ->setViewer($viewer) ->withNames(array($book_name)) + ->needRepositories(true) ->executeOne(); if (!$book) { return new Aphront404Response(); } $actions = $this->buildActionView($viewer, $book); $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); $crumbs->addTextCrumb( $book->getShortTitle(), '/book/'.$book->getName().'/'); $action_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('Actions')) ->setHref('#') ->setIconFont('fa-bars') ->addClass('phui-mobile-menu') ->setDropdownMenu($actions); $header = id(new PHUIHeaderView()) ->setHeader($book->getTitle()) ->setUser($viewer) ->setPolicyObject($book) ->setEpoch($book->getDateModified()) ->addActionLink($action_button); + // TODO: This could probably look better. + if ($book->getRepositoryPHID()) { + $header->addTag( + id(new PHUITagView()) + ->setType(PHUITagView::TYPE_STATE) + ->setBackgroundColor(PHUITagView::COLOR_BLUE) + ->setName($book->getRepository()->getMonogram())); + } + $document = new PHUIDocumentView(); $document->setHeader($header); $document->addClass('diviner-view'); $document->setFontKit(PHUIDocumentView::FONT_SOURCE_SANS); $atoms = id(new DivinerAtomQuery()) ->setViewer($viewer) ->withBookPHIDs(array($book->getPHID())) ->withGhosts(false) ->withIsDocumentable(true) ->execute(); $atoms = msort($atoms, 'getSortKey'); $group_spec = $book->getConfig('groups'); if (!is_array($group_spec)) { $group_spec = array(); } $groups = mgroup($atoms, 'getGroupName'); $groups = array_select_keys($groups, array_keys($group_spec)) + $groups; if (isset($groups[''])) { $no_group = $groups['']; unset($groups['']); $groups[''] = $no_group; } $out = array(); foreach ($groups as $group => $atoms) { $group_name = $book->getGroupName($group); if (!strlen($group_name)) { $group_name = pht('Free Radicals'); } $section = id(new DivinerSectionView()) ->setHeader($group_name); $section->addContent($this->renderAtomList($atoms)); $out[] = $section; } $preface = $book->getPreface(); $preface_view = null; if (strlen($preface)) { $preface_view = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent($preface), 'default', $viewer); } $document->appendChild($preface_view); $document->appendChild($out); return $this->buildApplicationPage( array( $crumbs, $document, ), array( 'title' => $book->getTitle(), )); } private function buildActionView( PhabricatorUser $user, DivinerLiveBook $book) { $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $book, PhabricatorPolicyCapability::CAN_EDIT); $action_view = id(new PhabricatorActionListView()) ->setUser($user) ->setObject($book) ->setObjectURI($this->getRequest()->getRequestURI()); $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Book')) ->setIcon('fa-pencil') ->setHref('/book/'.$book->getName().'/edit/') ->setDisabled(!$can_edit)); return $action_view; } } diff --git a/src/applications/diviner/controller/DivinerBookEditController.php b/src/applications/diviner/controller/DivinerBookEditController.php index 3ac8c4fe2e..b5c3e2dea5 100644 --- a/src/applications/diviner/controller/DivinerBookEditController.php +++ b/src/applications/diviner/controller/DivinerBookEditController.php @@ -1,117 +1,127 @@ getViewer(); $book_name = $request->getURIData('book'); $book = id(new DivinerBookQuery()) ->setViewer($viewer) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->needProjectPHIDs(true) ->withNames(array($book_name)) ->executeOne(); if (!$book) { return new Aphront404Response(); } $view_uri = '/book/'.$book->getName().'/'; if ($request->isFormPost()) { $v_projects = $request->getArr('projectPHIDs'); $v_view = $request->getStr('viewPolicy'); $v_edit = $request->getStr('editPolicy'); $xactions = array(); $xactions[] = id(new DivinerLiveBookTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setNewValue( array( '=' => array_fuse($v_projects), )); $xactions[] = id(new DivinerLiveBookTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($v_view); $xactions[] = id(new DivinerLiveBookTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($v_edit); id(new DivinerLiveBookEditor()) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request) ->setActor($viewer) ->applyTransactions($book, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Edit Basics')); $title = pht('Edit %s', $book->getTitle()); $policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->setObject($book) ->execute(); $view_capability = PhabricatorPolicyCapability::CAN_VIEW; $edit_capability = PhabricatorPolicyCapability::CAN_EDIT; $form = id(new AphrontFormView()) ->setUser($viewer) ->appendControl( id(new AphrontFormTokenizerControl()) ->setDatasource(new PhabricatorProjectDatasource()) ->setName('projectPHIDs') ->setLabel(pht('Projects')) ->setValue($book->getProjectPHIDs())) + ->appendControl( + id(new AphrontFormTokenizerControl()) + ->setDatasource(new DiffusionRepositoryDatasource()) + ->setName('repositoryPHIDs') + ->setLabel(pht('Repository')) + ->setDisableBehavior(true) + ->setLimit(1) + ->setValue($book->getRepositoryPHID() + ? array($book->getRepositoryPHID()) + : null)) ->appendChild( id(new AphrontFormPolicyControl()) ->setName('viewPolicy') ->setPolicyObject($book) ->setCapability($view_capability) ->setPolicies($policies) ->setCaption($book->describeAutomaticCapability($view_capability))) ->appendChild( id(new AphrontFormPolicyControl()) ->setName('editPolicy') ->setPolicyObject($book) ->setCapability($edit_capability) ->setPolicies($policies) ->setCaption($book->describeAutomaticCapability($edit_capability))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Save')) ->addCancelButton($view_uri)); $object_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setForm($form); $timeline = $this->buildTransactionTimeline( $book, new DivinerLiveBookTransactionQuery()); $timeline->setShouldTerminate(true); return $this->buildApplicationPage( array( $crumbs, $object_box, $timeline, ), array( 'title' => $title, )); } } diff --git a/src/applications/diviner/publisher/DivinerLivePublisher.php b/src/applications/diviner/publisher/DivinerLivePublisher.php index cdb3fe1936..4af0a68c8e 100644 --- a/src/applications/diviner/publisher/DivinerLivePublisher.php +++ b/src/applications/diviner/publisher/DivinerLivePublisher.php @@ -1,159 +1,177 @@ book) { $book_name = $this->getConfig('name'); $book = id(new DivinerLiveBook())->loadOneWhere( 'name = %s', $book_name); if (!$book) { $book = id(new DivinerLiveBook()) ->setName($book_name) ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) ->setEditPolicy(PhabricatorPolicies::POLICY_ADMIN) ->save(); } - $book->setConfigurationData($this->getConfigurationData())->save(); + $conn_w = $book->establishConnection('w'); + $conn_w->openTransaction(); + + $book + ->setRepositoryPHID($this->getRepositoryPHID()) + ->setConfigurationData($this->getConfigurationData()) + ->save(); + + // TODO: This is gross. Without this, the repository won't be updated for + // atoms which have already been published. + queryfx( + $conn_w, + 'UPDATE %T SET repositoryPHID = %s WHERE bookPHID = %s', + id(new DivinerLiveSymbol())->getTableName(), + $this->getRepositoryPHID(), + $book->getPHID()); + + $conn_w->saveTransaction(); $this->book = $book; id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing($book->getPHID()); } return $this->book; } private function loadSymbolForAtom(DivinerAtom $atom) { $symbol = id(new DivinerAtomQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->withBookPHIDs(array($this->loadBook()->getPHID())) + ->withBookPHIDs(array($atom->getBook())) ->withTypes(array($atom->getType())) ->withNames(array($atom->getName())) ->withContexts(array($atom->getContext())) ->withIndexes(array($this->getAtomSimilarIndex($atom))) ->executeOne(); if ($symbol) { return $symbol; } return id(new DivinerLiveSymbol()) - ->setBookPHID($this->loadBook()->getPHID()) + ->setBookPHID($this->getBook()->getPHID()) ->setType($atom->getType()) ->setName($atom->getName()) ->setContext($atom->getContext()) ->setAtomIndex($this->getAtomSimilarIndex($atom)); } private function loadAtomStorageForSymbol(DivinerLiveSymbol $symbol) { $storage = id(new DivinerLiveAtom())->loadOneWhere( 'symbolPHID = %s', $symbol->getPHID()); if ($storage) { return $storage; } return id(new DivinerLiveAtom()) ->setSymbolPHID($symbol->getPHID()); } protected function loadAllPublishedHashes() { $symbols = id(new DivinerAtomQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->withBookPHIDs(array($this->loadBook()->getPHID())) + ->withBookPHIDs(array($this->getBook()->getPHID())) ->withGhosts(false) ->execute(); return mpull($symbols, 'getGraphHash'); } protected function deleteDocumentsByHash(array $hashes) { $atom_table = new DivinerLiveAtom(); $symbol_table = new DivinerLiveSymbol(); $conn_w = $symbol_table->establishConnection('w'); $strings = array(); foreach ($hashes as $hash) { $strings[] = qsprintf($conn_w, '%s', $hash); } foreach (PhabricatorLiskDAO::chunkSQL($strings, ', ') as $chunk) { queryfx( $conn_w, 'UPDATE %T SET graphHash = NULL, nodeHash = NULL WHERE graphHash IN (%Q)', $symbol_table->getTableName(), $chunk); } queryfx( $conn_w, 'DELETE a FROM %T a LEFT JOIN %T s ON a.symbolPHID = s.phid WHERE s.graphHash IS NULL', $atom_table->getTableName(), $symbol_table->getTableName()); } protected function createDocumentsByHash(array $hashes) { foreach ($hashes as $hash) { $atom = $this->getAtomFromGraphHash($hash); $ref = $atom->getRef(); $symbol = $this->loadSymbolForAtom($atom); $is_documentable = $this->shouldGenerateDocumentForAtom($atom); $symbol + ->setRepositoryPHID($this->getRepositoryPHID()) ->setGraphHash($hash) ->setIsDocumentable((int)$is_documentable) ->setTitle($ref->getTitle()) ->setGroupName($ref->getGroup()) ->setNodeHash($atom->getHash()); if ($atom->getType() !== DivinerAtom::TYPE_FILE) { $renderer = $this->getRenderer(); $summary = $renderer->getAtomSummary($atom); $symbol->setSummary($summary); } else { $symbol->setSummary(''); } $symbol->save(); id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing($symbol->getPHID()); // TODO: We probably need a finer-grained sense of what "documentable" // atoms are. Neither files nor methods are currently considered // documentable, but for different reasons: files appear nowhere, while // methods just don't appear at the top level. These are probably // separate concepts. Since we need atoms in order to build method // documentation, we insert them here. This also means we insert files, // which are unnecessary and unused. Make sure this makes sense, but then // probably introduce separate "isTopLevel" and "isDocumentable" flags? // TODO: Yeah do that soon ^^^ if ($atom->getType() !== DivinerAtom::TYPE_FILE) { $storage = $this->loadAtomStorageForSymbol($symbol) ->setAtomData($atom->toDictionary()) ->setContent(null) ->save(); } } } public function findAtomByRef(DivinerAtomRef $ref) { // TODO: Actually implement this. return null; } } diff --git a/src/applications/diviner/publisher/DivinerPublisher.php b/src/applications/diviner/publisher/DivinerPublisher.php index 4b6490639d..401a1a331f 100644 --- a/src/applications/diviner/publisher/DivinerPublisher.php +++ b/src/applications/diviner/publisher/DivinerPublisher.php @@ -1,166 +1,176 @@ dropCaches = $drop_caches; return $this; } final public function setRenderer(DivinerRenderer $renderer) { $renderer->setPublisher($this); $this->renderer = $renderer; return $this; } final public function getRenderer() { return $this->renderer; } final public function setConfig(array $config) { $this->config = $config; return $this; } final public function getConfig($key, $default = null) { return idx($this->config, $key, $default); } final public function getConfigurationData() { return $this->config; } final public function setAtomCache(DivinerAtomCache $cache) { $this->atomCache = $cache; $graph_map = $this->atomCache->getGraphMap(); $this->atomGraphHashToNodeHashMap = array_flip($graph_map); return $this; } final protected function getAtomFromGraphHash($graph_hash) { if (empty($this->atomGraphHashToNodeHashMap[$graph_hash])) { throw new Exception(pht("No such atom '%s'!", $graph_hash)); } return $this->getAtomFromNodeHash( $this->atomGraphHashToNodeHashMap[$graph_hash]); } final protected function getAtomFromNodeHash($node_hash) { if (empty($this->atomMap[$node_hash])) { $dict = $this->atomCache->getAtom($node_hash); $this->atomMap[$node_hash] = DivinerAtom::newFromDictionary($dict); } return $this->atomMap[$node_hash]; } final protected function getSimilarAtoms(DivinerAtom $atom) { if ($this->symbolReverseMap === null) { $rmap = array(); $smap = $this->atomCache->getSymbolMap(); foreach ($smap as $nhash => $shash) { $rmap[$shash][$nhash] = true; } $this->symbolReverseMap = $rmap; } $shash = $atom->getRef()->toHash(); if (empty($this->symbolReverseMap[$shash])) { throw new Exception(pht('Atom has no symbol map entry!')); } $hashes = $this->symbolReverseMap[$shash]; $atoms = array(); foreach ($hashes as $hash => $ignored) { $atoms[] = $this->getAtomFromNodeHash($hash); } $atoms = msort($atoms, 'getSortKey'); return $atoms; } /** * If a book contains multiple definitions of some atom, like some function * `f()`, we assign them an arbitrary (but fairly stable) order and publish * them as `function/f/1/`, `function/f/2/`, etc., or similar. */ final protected function getAtomSimilarIndex(DivinerAtom $atom) { $atoms = $this->getSimilarAtoms($atom); if (count($atoms) == 1) { return 0; } $index = 1; foreach ($atoms as $similar_atom) { if ($atom === $similar_atom) { return $index; } $index++; } throw new Exception(pht('Expected to find atom while disambiguating!')); } abstract protected function loadAllPublishedHashes(); abstract protected function deleteDocumentsByHash(array $hashes); abstract protected function createDocumentsByHash(array $hashes); abstract public function findAtomByRef(DivinerAtomRef $ref); final public function publishAtoms(array $hashes) { $existing = $this->loadAllPublishedHashes(); if ($this->dropCaches) { $deleted = $existing; $created = $hashes; } else { $existing_map = array_fill_keys($existing, true); $hashes_map = array_fill_keys($hashes, true); $deleted = array_diff_key($existing_map, $hashes_map); $created = array_diff_key($hashes_map, $existing_map); $deleted = array_keys($deleted); $created = array_keys($created); } $console = PhutilConsole::getConsole(); $console->writeOut( "%s\n", pht( 'Deleting %s document(s).', new PhutilNumber(count($deleted)))); $this->deleteDocumentsByHash($deleted); $console->writeOut( "%s\n", pht( 'Creating %s document(s).', new PhutilNumber(count($created)))); $this->createDocumentsByHash($created); } final protected function shouldGenerateDocumentForAtom(DivinerAtom $atom) { switch ($atom->getType()) { case DivinerAtom::TYPE_METHOD: case DivinerAtom::TYPE_FILE: return false; case DivinerAtom::TYPE_ARTICLE: default: break; } return true; } + final public function getRepositoryPHID() { + return $this->repositoryPHID; + } + + final public function setRepositoryPHID($repository_phid) { + $this->repositoryPHID = $repository_phid; + return $this; + } + } diff --git a/src/applications/diviner/query/DivinerAtomQuery.php b/src/applications/diviner/query/DivinerAtomQuery.php index b8856141a5..c2a5247d78 100644 --- a/src/applications/diviner/query/DivinerAtomQuery.php +++ b/src/applications/diviner/query/DivinerAtomQuery.php @@ -1,465 +1,511 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withBookPHIDs(array $phids) { $this->bookPHIDs = $phids; return $this; } public function withTypes(array $types) { $this->types = $types; return $this; } public function withNames(array $names) { $this->names = $names; return $this; } public function withContexts(array $contexts) { $this->contexts = $contexts; return $this; } public function withIndexes(array $indexes) { $this->indexes = $indexes; return $this; } public function withNodeHashes(array $hashes) { $this->nodeHashes = $hashes; return $this; } public function withTitles($titles) { $this->titles = $titles; return $this; } public function withNameContains($text) { $this->nameContains = $text; return $this; } public function needAtoms($need) { $this->needAtoms = $need; return $this; } public function needChildren($need) { $this->needChildren = $need; return $this; } /** * Include or exclude "ghosts", which are symbols which used to exist but do * not exist currently (for example, a function which existed in an older * version of the codebase but was deleted). * * These symbols had PHIDs assigned to them, and may have other sorts of * metadata that we don't want to lose (like comments or flags), so we don't * delete them outright. They might also come back in the future: the change * which deleted the symbol might be reverted, or the documentation might * have been generated incorrectly by accident. In these cases, we can * restore the original data. * * @param bool * @return this */ public function withGhosts($ghosts) { $this->isGhost = $ghosts; return $this; } public function needExtends($need) { $this->needExtends = $need; return $this; } public function withIsDocumentable($documentable) { $this->isDocumentable = $documentable; return $this; } + public function withRepositoryPHIDs(array $repository_phids) { + $this->repositoryPHIDs = $repository_phids; + return $this; + } + + public function needRepositories($need_repositories) { + $this->needRepositories = $need_repositories; + return $this; + } + protected function loadPage() { $table = new DivinerLiveSymbol(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); return $table->loadAllFromArray($data); } protected function willFilterPage(array $atoms) { + assert_instances_of($atoms, 'DivinerLiveSymbol'); + $books = array_unique(mpull($atoms, 'getBookPHID')); $books = id(new DivinerBookQuery()) ->setViewer($this->getViewer()) ->withPHIDs($books) ->execute(); $books = mpull($books, null, 'getPHID'); foreach ($atoms as $key => $atom) { $book = idx($books, $atom->getBookPHID()); if (!$book) { $this->didRejectResult($atom); unset($atoms[$key]); continue; } $atom->attachBook($book); } if ($this->needAtoms) { $atom_data = id(new DivinerLiveAtom())->loadAllWhere( 'symbolPHID IN (%Ls)', mpull($atoms, 'getPHID')); $atom_data = mpull($atom_data, null, 'getSymbolPHID'); foreach ($atoms as $key => $atom) { $data = idx($atom_data, $atom->getPHID()); $atom->attachAtom($data); } } // Load all of the symbols this symbol extends, recursively. Commonly, // this means all the ancestor classes and interfaces it extends and // implements. if ($this->needExtends) { // First, load all the matching symbols by name. This does 99% of the // work in most cases, assuming things are named at all reasonably. $names = array(); foreach ($atoms as $atom) { if (!$atom->getAtom()) { continue; } foreach ($atom->getAtom()->getExtends() as $xref) { $names[] = $xref->getName(); } } if ($names) { $xatoms = id(new DivinerAtomQuery()) ->setViewer($this->getViewer()) ->withNames($names) ->withGhosts(false) ->needExtends(true) ->needAtoms(true) ->needChildren($this->needChildren) ->execute(); $xatoms = mgroup($xatoms, 'getName', 'getType', 'getBookPHID'); } else { $xatoms = array(); } foreach ($atoms as $atom) { $atom_lang = null; $atom_extends = array(); if ($atom->getAtom()) { $atom_lang = $atom->getAtom()->getLanguage(); $atom_extends = $atom->getAtom()->getExtends(); } $extends = array(); foreach ($atom_extends as $xref) { // If there are no symbols of the matching name and type, we can't // resolve this. if (empty($xatoms[$xref->getName()][$xref->getType()])) { continue; } // If we found matches in the same documentation book, prefer them // over other matches. Otherwise, look at all the the matches. $matches = $xatoms[$xref->getName()][$xref->getType()]; if (isset($matches[$atom->getBookPHID()])) { $maybe = $matches[$atom->getBookPHID()]; } else { $maybe = array_mergev($matches); } if (!$maybe) { continue; } // Filter out matches in a different language, since, e.g., PHP // classes can not implement JS classes. $same_lang = array(); foreach ($maybe as $xatom) { if ($xatom->getAtom()->getLanguage() == $atom_lang) { $same_lang[] = $xatom; } } if (!$same_lang) { continue; } // If we have duplicates remaining, just pick the first one. There's // nothing more we can do to figure out which is the real one. $extends[] = head($same_lang); } $atom->attachExtends($extends); } } if ($this->needChildren) { $child_hashes = $this->getAllChildHashes($atoms, $this->needExtends); if ($child_hashes) { $children = id(new DivinerAtomQuery()) ->setViewer($this->getViewer()) ->withNodeHashes($child_hashes) ->needAtoms($this->needAtoms) ->execute(); $children = mpull($children, null, 'getNodeHash'); } else { $children = array(); } $this->attachAllChildren($atoms, $children, $this->needExtends); } + if ($this->needRepositories) { + $repositories = id(new PhabricatorRepositoryQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs(mpull($atoms, 'getRepositoryPHID')) + ->execute(); + $repositories = mpull($repositories, null, 'getPHID'); + + foreach ($atoms as $key => $atom) { + if ($atom->getRepositoryPHID() === null) { + $atom->attachRepository(null); + continue; + } + + $repository = idx($repositories, $atom->getRepositoryPHID()); + + if (!$repository) { + $this->didRejectResult($atom); + unset($atom[$key]); + continue; + } + + $atom->attachRepository($repository); + } + } + return $atoms; } protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); if ($this->ids) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn_r, 'phid IN (%Ls)', $this->phids); } if ($this->bookPHIDs) { $where[] = qsprintf( $conn_r, 'bookPHID IN (%Ls)', $this->bookPHIDs); } if ($this->types) { $where[] = qsprintf( $conn_r, 'type IN (%Ls)', $this->types); } if ($this->names) { $where[] = qsprintf( $conn_r, 'name IN (%Ls)', $this->names); } if ($this->titles) { $hashes = array(); foreach ($this->titles as $title) { $slug = DivinerAtomRef::normalizeTitleString($title); $hash = PhabricatorHash::digestForIndex($slug); $hashes[] = $hash; } $where[] = qsprintf( $conn_r, 'titleSlugHash in (%Ls)', $hashes); } if ($this->contexts) { $with_null = false; $contexts = $this->contexts; foreach ($contexts as $key => $value) { if ($value === null) { unset($contexts[$key]); $with_null = true; continue; } } if ($contexts && $with_null) { $where[] = qsprintf( $conn_r, 'context IN (%Ls) OR context IS NULL', $contexts); } else if ($contexts) { $where[] = qsprintf( $conn_r, 'context IN (%Ls)', $contexts); } else if ($with_null) { $where[] = qsprintf( $conn_r, 'context IS NULL'); } } if ($this->indexes) { $where[] = qsprintf( $conn_r, 'atomIndex IN (%Ld)', $this->indexes); } if ($this->isDocumentable !== null) { $where[] = qsprintf( $conn_r, 'isDocumentable = %d', (int)$this->isDocumentable); } if ($this->isGhost !== null) { if ($this->isGhost) { $where[] = qsprintf($conn_r, 'graphHash IS NULL'); } else { $where[] = qsprintf($conn_r, 'graphHash IS NOT NULL'); } } if ($this->nodeHashes) { $where[] = qsprintf( $conn_r, 'nodeHash IN (%Ls)', $this->nodeHashes); } if ($this->nameContains) { // NOTE: This `CONVERT()` call makes queries case-insensitive, since // the column has binary collation. Eventually, this should move into // fulltext. $where[] = qsprintf( $conn_r, 'CONVERT(name USING utf8) LIKE %~', $this->nameContains); } + if ($this->repositoryPHIDs) { + $where[] = qsprintf( + $conn_r, + 'repositoryPHID IN (%Ls)', + $this->repositoryPHIDs); + } + $where[] = $this->buildPagingClause($conn_r); return $this->formatWhereClause($where); } /** * Walk a list of atoms and collect all the node hashes of the atoms' * children. When recursing, also walk up the tree and collect children of * atoms they extend. * * @param list List of symbols to collect child hashes of. * @param bool True to collect children of extended atoms, * as well. * @return map Hashes of atoms' children. */ private function getAllChildHashes(array $symbols, $recurse_up) { assert_instances_of($symbols, 'DivinerLiveSymbol'); $hashes = array(); foreach ($symbols as $symbol) { $child_hashes = array(); if ($symbol->getAtom()) { $child_hashes = $symbol->getAtom()->getChildHashes(); } foreach ($child_hashes as $hash) { $hashes[$hash] = $hash; } if ($recurse_up) { $hashes += $this->getAllChildHashes($symbol->getExtends(), true); } } return $hashes; } /** * Attach child atoms to existing atoms. In recursive mode, also attach child * atoms to atoms that these atoms extend. * * @param list List of symbols to attach children to. * @param map Map of symbols, keyed by node hash. * @param bool True to attach children to extended atoms, as well. * @return void */ private function attachAllChildren( array $symbols, array $children, $recurse_up) { assert_instances_of($symbols, 'DivinerLiveSymbol'); assert_instances_of($children, 'DivinerLiveSymbol'); foreach ($symbols as $symbol) { $child_hashes = array(); $symbol_children = array(); if ($symbol->getAtom()) { $child_hashes = $symbol->getAtom()->getChildHashes(); } foreach ($child_hashes as $hash) { if (isset($children[$hash])) { $symbol_children[] = $children[$hash]; } } $symbol->attachChildren($symbol_children); if ($recurse_up) { $this->attachAllChildren($symbol->getExtends(), $children, true); } } } public function getQueryApplicationClass() { return 'PhabricatorDivinerApplication'; } } diff --git a/src/applications/diviner/query/DivinerAtomSearchEngine.php b/src/applications/diviner/query/DivinerAtomSearchEngine.php index d1c0f734ed..70f77f3195 100644 --- a/src/applications/diviner/query/DivinerAtomSearchEngine.php +++ b/src/applications/diviner/query/DivinerAtomSearchEngine.php @@ -1,122 +1,135 @@ setParameter( + 'repositoryPHIDs', + $this->readPHIDsFromRequest($request, 'repositoryPHIDs')); + $saved->setParameter('name', $request->getStr('name')); $saved->setParameter( 'types', $this->readListFromRequest($request, 'types')); - $saved->setParameter('name', $request->getStr('name')); - return $saved; } public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new DivinerAtomQuery()); - $types = $saved->getParameter('types'); - if ($types) { - $query->withTypes($types); + $repository_phids = $saved->getParameter('repositoryPHIDs'); + if ($repository_phids) { + $query->withRepositoryPHIDs($repository_phids); } $name = $saved->getParameter('name'); if ($name) { $query->withNameContains($name); } + $types = $saved->getParameter('types'); + if ($types) { + $query->withTypes($types); + } + return $query; } public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { + $form->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Name Contains')) + ->setName('name') + ->setValue($saved->getParameter('name'))); + $all_types = array(); foreach (DivinerAtom::getAllTypes() as $type) { $all_types[$type] = DivinerAtom::getAtomTypeNameString($type); } asort($all_types); $types = $saved->getParameter('types', array()); $types = array_fuse($types); $type_control = id(new AphrontFormCheckboxControl()) ->setLabel(pht('Types')); foreach ($all_types as $type => $name) { $type_control->addCheckbox( 'types[]', $type, $name, isset($types[$type])); } - - $form - ->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('Name Contains')) - ->setName('name') - ->setValue($saved->getParameter('name'))) - ->appendChild($type_control); + $form->appendChild($type_control); + + $form->appendControl( + id(new AphrontFormTokenizerControl()) + ->setLabel(pht('Repositories')) + ->setName('repositoryPHIDs') + ->setDatasource(new DiffusionRepositoryDatasource()) + ->setValue($saved->getParameter('repositoryPHIDs'))); } protected function getURI($path) { return '/diviner/'.$path; } protected function getBuiltinQueryNames() { return array( 'all' => pht('All'), ); } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; } return parent::buildSavedQueryFromBuiltin($query_key); } protected function renderResultList( array $symbols, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($symbols, 'DivinerLiveSymbol'); $viewer = $this->requireViewer(); $list = id(new PHUIObjectItemListView()) ->setUser($viewer); foreach ($symbols as $symbol) { $type = $symbol->getType(); $type_name = DivinerAtom::getAtomTypeNameString($type); $item = id(new PHUIObjectItemView()) ->setHeader($symbol->getTitle()) ->setHref($symbol->getURI()) ->addAttribute($symbol->getSummary()) ->addIcon('none', $type_name); $list->addItem($item); } return $list; } } diff --git a/src/applications/diviner/query/DivinerBookQuery.php b/src/applications/diviner/query/DivinerBookQuery.php index b1f5f0a995..dfd236f3d1 100644 --- a/src/applications/diviner/query/DivinerBookQuery.php +++ b/src/applications/diviner/query/DivinerBookQuery.php @@ -1,103 +1,147 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withNames(array $names) { $this->names = $names; return $this; } + public function withRepositoryPHIDs(array $repository_phids) { + $this->repositoryPHIDs = $repository_phids; + return $this; + } + public function needProjectPHIDs($need_phids) { $this->needProjectPHIDs = $need_phids; return $this; } + public function needRepositories($need_repositories) { + $this->needRepositories = $need_repositories; + return $this; + } + protected function loadPage() { $table = new DivinerLiveBook(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); return $table->loadAllFromArray($data); } protected function didFilterPage(array $books) { assert_instances_of($books, 'DivinerLiveBook'); + if ($this->needRepositories) { + $repositories = id(new PhabricatorRepositoryQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs(mpull($books, 'getRepositoryPHID')) + ->execute(); + $repositories = mpull($repositories, null, 'getPHID'); + + foreach ($books as $key => $book) { + if ($book->getRepositoryPHID() === null) { + $book->attachRepository(null); + continue; + } + + $repository = idx($repositories, $book->getRepositoryPHID()); + + if (!$repository) { + $this->didRejectResult($book); + unset($books[$key]); + continue; + } + + $book->attachRepository($repository); + } + } + if ($this->needProjectPHIDs) { $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(mpull($books, 'getPHID')) ->withEdgeTypes( array( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, )); $edge_query->execute(); foreach ($books as $book) { $project_phids = $edge_query->getDestinationPHIDs( array( $book->getPHID(), )); $book->attachProjectPHIDs($project_phids); } } return $books; } protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); if ($this->ids) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn_r, 'phid IN (%Ls)', $this->phids); } if ($this->names) { $where[] = qsprintf( $conn_r, 'name IN (%Ls)', $this->names); } + if ($this->repositoryPHIDs !== null) { + $where[] = qsprintf( + $conn_r, + 'repositoryPHID IN (%Ls)', + $this->repositoryPHIDs); + } + $where[] = $this->buildPagingClause($conn_r); return $this->formatWhereClause($where); } public function getQueryApplicationClass() { return 'PhabricatorDivinerApplication'; } } diff --git a/src/applications/diviner/search/DivinerAtomSearchIndexer.php b/src/applications/diviner/search/DivinerAtomSearchIndexer.php index 51a3866641..0719f807f6 100644 --- a/src/applications/diviner/search/DivinerAtomSearchIndexer.php +++ b/src/applications/diviner/search/DivinerAtomSearchIndexer.php @@ -1,43 +1,49 @@ loadDocumentByPHID($phid); $book = $atom->getBook(); if (!$atom->getIsDocumentable()) { return null; } $doc = $this->newDocument($phid) ->setDocumentTitle($atom->getTitle()) ->setDocumentCreated($book->getDateCreated()) ->setDocumentModified($book->getDateModified()); $doc->addField( PhabricatorSearchDocumentFieldType::FIELD_BODY, $atom->getSummary()); $doc->addRelationship( PhabricatorSearchRelationship::RELATIONSHIP_BOOK, $atom->getBookPHID(), DivinerBookPHIDType::TYPECONST, PhabricatorTime::getNow()); + $doc->addRelationship( + PhabricatorSearchRelationship::RELATIONSHIP_REPOSITORY, + $atom->getRepositoryPHID(), + PhabricatorRepositoryRepositoryPHIDType::TYPECONST, + PhabricatorTime::getNow()); + $doc->addRelationship( $atom->getGraphHash() ? PhabricatorSearchRelationship::RELATIONSHIP_CLOSED : PhabricatorSearchRelationship::RELATIONSHIP_OPEN, $atom->getBookPHID(), DivinerBookPHIDType::TYPECONST, PhabricatorTime::getNow()); return $doc; } } diff --git a/src/applications/diviner/search/DivinerBookSearchIndexer.php b/src/applications/diviner/search/DivinerBookSearchIndexer.php index 106ae9e389..3cc819e275 100644 --- a/src/applications/diviner/search/DivinerBookSearchIndexer.php +++ b/src/applications/diviner/search/DivinerBookSearchIndexer.php @@ -1,30 +1,36 @@ loadDocumentByPHID($phid); $doc = $this->newDocument($phid) ->setDocumentTitle($book->getTitle()) ->setDocumentCreated($book->getDateCreated()) ->setDocumentModified($book->getDateModified()); $doc->addField( PhabricatorSearchDocumentFieldType::FIELD_BODY, $book->getPreface()); + $doc->addRelationship( + PhabricatorSearchRelationship::RELATIONSHIP_REPOSITORY, + $book->getRepositoryPHID(), + PhabricatorRepositoryRepositoryPHIDType::TYPECONST, + $book->getDateCreated()); + $this->indexTransactions( $doc, new DivinerLiveBookTransactionQuery(), array($phid)); return $doc; } } diff --git a/src/applications/diviner/storage/DivinerLiveBook.php b/src/applications/diviner/storage/DivinerLiveBook.php index e689955171..872dcb6eeb 100644 --- a/src/applications/diviner/storage/DivinerLiveBook.php +++ b/src/applications/diviner/storage/DivinerLiveBook.php @@ -1,152 +1,164 @@ true, self::CONFIG_SERIALIZATION => array( 'configurationData' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text64', + 'repositoryPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'name' => array( 'columns' => array('name'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function getConfig($key, $default = null) { return idx($this->configurationData, $key, $default); } public function setConfig($key, $value) { $this->configurationData[$key] = $value; return $this; } public function generatePHID() { return PhabricatorPHID::generateNewPHID(DivinerBookPHIDType::TYPECONST); } public function getTitle() { return $this->getConfig('title', $this->getName()); } public function getShortTitle() { return $this->getConfig('short', $this->getTitle()); } public function getPreface() { return $this->getConfig('preface'); } public function getGroupName($group) { $groups = $this->getConfig('groups', array()); $spec = idx($groups, $group, array()); return idx($spec, 'name', $group); } + public function attachRepository(PhabricatorRepository $repository = null) { + $this->repository = $repository; + return $this; + } + + public function getRepository() { + return $this->assertAttached($this->repository); + } + public function attachProjectPHIDs(array $project_phids) { $this->projectPHIDs = $project_phids; return $this; } public function getProjectPHIDs() { return $this->assertAttached($this->projectPHIDs); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { - return false; + return false; } public function describeAutomaticCapability($capability) { return null; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $atoms = id(new DivinerAtomQuery()) ->setViewer($engine->getViewer()) ->withBookPHIDs(array($this->getPHID())) ->execute(); foreach ($atoms as $atom) { $engine->destroyObject($atom); } $this->delete(); $this->saveTransaction(); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new DivinerLiveBookEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new DivinerLiveBookTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } } diff --git a/src/applications/diviner/storage/DivinerLiveSymbol.php b/src/applications/diviner/storage/DivinerLiveSymbol.php index d6fbc710df..bdda976081 100644 --- a/src/applications/diviner/storage/DivinerLiveSymbol.php +++ b/src/applications/diviner/storage/DivinerLiveSymbol.php @@ -1,271 +1,283 @@ true, self::CONFIG_TIMESTAMPS => false, self::CONFIG_COLUMN_SCHEMA => array( 'context' => 'text255?', 'type' => 'text32', 'name' => 'text255', 'atomIndex' => 'uint32', 'identityHash' => 'bytes12', 'graphHash' => 'text64?', 'title' => 'text?', 'titleSlugHash' => 'bytes12?', 'groupName' => 'text255?', 'summary' => 'text?', 'isDocumentable' => 'bool', 'nodeHash' => 'text64?', + 'repositoryPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'identityHash' => array( 'columns' => array('identityHash'), 'unique' => true, ), 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'graphHash' => array( 'columns' => array('graphHash'), 'unique' => true, ), 'nodeHash' => array( 'columns' => array('nodeHash'), 'unique' => true, ), 'bookPHID' => array( 'columns' => array( 'bookPHID', 'type', 'name(64)', 'context(64)', 'atomIndex', ), ), 'name' => array( 'columns' => array('name(64)'), ), 'key_slug' => array( 'columns' => array('titleSlugHash'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID(DivinerAtomPHIDType::TYPECONST); } public function getBook() { return $this->assertAttached($this->book); } public function attachBook(DivinerLiveBook $book) { $this->book = $book; return $this; } + public function getRepository() { + return $this->assertAttached($this->repository); + } + + public function attachRepository(PhabricatorRepository $repository = null) { + $this->repository = $repository; + return $this; + } + public function getAtom() { return $this->assertAttached($this->atom); } public function attachAtom(DivinerLiveAtom $atom = null) { if ($atom === null) { $this->atom = null; } else { $this->atom = DivinerAtom::newFromDictionary($atom->getAtomData()); } return $this; } public function getURI() { $parts = array( 'book', $this->getBook()->getName(), $this->getType(), ); if ($this->getContext()) { $parts[] = $this->getContext(); } $parts[] = $this->getName(); if ($this->getAtomIndex()) { $parts[] = $this->getAtomIndex(); } return '/'.implode('/', $parts).'/'; } public function getSortKey() { // Sort articles before other types of content. Then, sort atoms in a // case-insensitive way. return sprintf( '%c:%s', ($this->getType() == DivinerAtom::TYPE_ARTICLE ? '0' : '1'), phutil_utf8_strtolower($this->getTitle())); } public function save() { // NOTE: The identity hash is just a sanity check because the unique tuple // on this table is way way too long to fit into a normal `UNIQUE KEY`. // We don't use it directly, but its existence prevents duplicate records. if (!$this->identityHash) { $this->identityHash = PhabricatorHash::digestForIndex( serialize( array( 'bookPHID' => $this->getBookPHID(), 'context' => $this->getContext(), 'type' => $this->getType(), 'name' => $this->getName(), 'index' => $this->getAtomIndex(), ))); } return parent::save(); } public function getTitle() { $title = parent::getTitle(); if (!strlen($title)) { $title = $this->getName(); } return $title; } public function setTitle($value) { $this->writeField('title', $value); if (strlen($value)) { $slug = DivinerAtomRef::normalizeTitleString($value); $hash = PhabricatorHash::digestForIndex($slug); $this->titleSlugHash = $hash; } else { $this->titleSlugHash = null; } return $this; } public function attachExtends(array $extends) { assert_instances_of($extends, __CLASS__); $this->extends = $extends; return $this; } public function getExtends() { return $this->assertAttached($this->extends); } public function attachChildren(array $children) { assert_instances_of($children, __CLASS__); $this->children = $children; return $this; } public function getChildren() { return $this->assertAttached($this->children); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return $this->getBook()->getCapabilities(); } public function getPolicy($capability) { return $this->getBook()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getBook()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht('Atoms inherit the policies of the books they are part of.'); } /* -( PhabricatorMarkupInterface )------------------------------------------ */ public function getMarkupFieldKey($field) { return $this->getPHID().':'.$field.':'.$this->getGraphHash(); } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::getEngine('diviner'); } public function getMarkupText($field) { if (!$this->getAtom()) { return; } return $this->getAtom()->getDocblockText(); } public function didMarkupText($field, $output, PhutilMarkupEngine $engine) { return $output; } public function shouldUseMarkupCache($field) { return true; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $conn_w = $this->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE symbolPHID = %s', id(new DivinerLiveAtom())->getTableName(), $this->getPHID()); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/diviner/workflow/DivinerGenerateWorkflow.php b/src/applications/diviner/workflow/DivinerGenerateWorkflow.php index a08180e4a1..a3847391c5 100644 --- a/src/applications/diviner/workflow/DivinerGenerateWorkflow.php +++ b/src/applications/diviner/workflow/DivinerGenerateWorkflow.php @@ -1,565 +1,588 @@ setName('generate') ->setSynopsis(pht('Generate documentation.')) ->setArguments( array( array( 'name' => 'clean', 'help' => pht('Clear the caches before generating documentation.'), ), array( 'name' => 'book', 'param' => 'path', 'help' => pht('Path to a Diviner book configuration.'), ), array( 'name' => 'publisher', 'param' => 'class', 'help' => pht('Specify a subclass of %s.', 'DivinerPublisher'), 'default' => 'DivinerLivePublisher', ), + array( + 'name' => 'repository', + 'param' => 'callsign', + 'help' => pht('Repository that the documentation belongs to.'), + ), )); } protected function getAtomCache() { if (!$this->atomCache) { $book_root = $this->getConfig('root'); $book_name = $this->getConfig('name'); $cache_directory = $book_root.'/.divinercache/'.$book_name; $this->atomCache = new DivinerAtomCache($cache_directory); } return $this->atomCache; } protected function log($message) { $console = PhutilConsole::getConsole(); $console->writeErr($message."\n"); } public function execute(PhutilArgumentParser $args) { $book = $args->getArg('book'); if ($book) { $books = array($book); } else { $cwd = getcwd(); $this->log(pht('FINDING DOCUMENTATION BOOKS')); $books = id(new FileFinder($cwd)) ->withType('f') ->withSuffix('book') ->find(); if (!$books) { throw new PhutilArgumentUsageException( pht( "There are no Diviner '%s' files anywhere beneath the current ". "directory. Use '%s' to specify a documentation book to generate.", '.book', '--book ')); } else { $this->log(pht('Found %s book(s).', new PhutilNumber(count($books)))); } } foreach ($books as $book) { $short_name = basename($book); $this->log(pht('Generating book "%s"...', $short_name)); $this->generateBook($book, $args); $this->log(pht('Completed generation of "%s".', $short_name)."\n"); } } private function generateBook($book, PhutilArgumentParser $args) { $this->atomCache = null; $this->readBookConfiguration($book); if ($args->getArg('clean')) { $this->log(pht('CLEARING CACHES')); $this->getAtomCache()->delete(); $this->log(pht('Done.')."\n"); } // The major challenge of documentation generation is one of dependency // management. When regenerating documentation, we want to do the smallest // amount of work we can, so that regenerating documentation after minor // changes is quick. // // = Atom Cache = // // In the first stage, we find all the direct changes to source code since // the last run. This stage relies on two data structures: // // - File Hash Map: `map` // - Atom Map: `map` // // First, we hash all the source files in the project to detect any which // have changed since the previous run (i.e., their hash is not present in // the File Hash Map). If a file's content hash appears in the map, it has // not changed, so we don't need to reparse it. // // We break the contents of each file into "atoms", which represent a unit // of source code (like a function, method, class or file). Each atom has a // "node hash" based on the content of the atom: if a function definition // changes, the node hash of the atom changes too. The primary output of // the atom cache is a list of node hashes which exist in the project. This // is the Atom Map. The node hash depends only on the definition of the atom // and the atomizer implementation. It ends with an "N", for "node". // // (We need the Atom Map in addition to the File Hash Map because each file // may have several atoms in it (e.g., multiple functions, or a class and // its methods). The File Hash Map contains an exhaustive list of all atoms // with type "file", but not child atoms of those top-level atoms.) // // = Graph Cache = // // We now know which atoms exist, and can compare the Atom Map to some // existing cache to figure out what has changed. However, this isn't // sufficient to figure out which documentation actually needs to be // regenerated, because atoms depend on other atoms. For example, if `B // extends A` and the definition for `A` changes, we need to regenerate the // documentation in `B`. Similarly, if `X` links to `Y` and `Y` changes, we // should regenerate `X`. (In both these cases, the documentation for the // connected atom may not actually change, but in some cases it will, and // the extra work we need to do is generally very small compared to the // size of the project.) // // To figure out which other nodes have changed, we compute a "graph hash" // for each node. This hash combines the "node hash" with the node hashes // of connected nodes. Our primary output is a list of graph hashes, which // a documentation generator can use to easily determine what work needs // to be done by comparing the list with a list of cached graph hashes, // then generating documentation for new hashes and deleting documentation // for missing hashes. The graph hash ends with a "G", for "graph". // // In this stage, we rely on three data structures: // // - Symbol Map: `map` // - Edge Map: `map>` // - Graph Map: `map` // // Calculating the graph hash requires several steps, because we need to // figure out which nodes an atom is attached to. The atom contains symbolic // references to other nodes by name (e.g., `extends SomeClass`) in the form // of @{class:DivinerAtomRefs}. We can also build a symbolic reference for // any atom from the atom itself. Each @{class:DivinerAtomRef} generates a // symbol hash, which ends with an "S", for "symbol". // // First, we update the symbol map. We remove (and mark dirty) any symbols // associated with node hashes which no longer exist (e.g., old/dead nodes). // Second, we add (and mark dirty) any symbols associated with new nodes. // We also add edges defined by new nodes to the graph. // // We initialize a list of dirty nodes to the list of new nodes, then find // all nodes connected to dirty symbols and add them to the dirty node list. // This list now contains every node with a new or changed graph hash. // // We walk the dirty list and compute the new graph hashes, adding them // to the graph hash map. This Graph Map can then be passed to an actual // documentation generator, which can compare the graph hashes to a list // of already-generated graph hashes and easily assess which documents need // to be regenerated and which can be deleted. $this->buildAtomCache(); $this->buildGraphCache(); $publisher_class = $args->getArg('publisher'); $symbols = id(new PhutilSymbolLoader()) ->setName($publisher_class) ->setConcreteOnly(true) ->setAncestorClass('DivinerPublisher') ->selectAndLoadSymbols(); if (!$symbols) { throw new PhutilArgumentUsageException( pht( "Publisher class '%s' must be a concrete subclass of %s.", $publisher_class, 'DivinerPublisher')); } $publisher = newv($publisher_class, array()); + $callsign = $args->getArg('repository'); + $repository = null; + if ($callsign) { + $repository = id(new PhabricatorRepositoryQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withCallsigns(array($callsign)) + ->executeOne(); + + if (!$repository) { + throw new PhutilArgumentUsageException( + pht( + "Repository '%s' does not exist.", + $callsign)); + } + + $publisher->setRepositoryPHID($repository->getPHID()); + } + $this->publishDocumentation($args->getArg('clean'), $publisher); } /* -( Atom Cache )--------------------------------------------------------- */ private function buildAtomCache() { $this->log(pht('BUILDING ATOM CACHE')); $file_hashes = $this->findFilesInProject(); $this->log( pht( 'Found %s file(s) in project.', new PhutilNumber(count($file_hashes)))); $this->deleteDeadAtoms($file_hashes); $atomize = $this->getFilesToAtomize($file_hashes); $this->log( pht( 'Found %s unatomized, uncached file(s).', new PhutilNumber(count($atomize)))); $file_atomizers = $this->getAtomizersForFiles($atomize); $this->log( pht( 'Found %s file(s) to atomize.', new PhutilNumber(count($file_atomizers)))); $futures = $this->buildAtomizerFutures($file_atomizers); $this->log( pht( 'Atomizing %s file(s).', new PhutilNumber(count($file_atomizers)))); if ($futures) { $this->resolveAtomizerFutures($futures, $file_hashes); $this->log(pht('Atomization complete.')); } else { $this->log(pht('Atom cache is up to date, no files to atomize.')); } $this->log(pht('Writing atom cache.')); $this->getAtomCache()->saveAtoms(); $this->log(pht('Done.')."\n"); } private function getAtomizersForFiles(array $files) { $rules = $this->getRules(); $exclude = $this->getExclude(); $atomizers = array(); foreach ($files as $file) { foreach ($exclude as $pattern) { if (preg_match($pattern, $file)) { continue 2; } } foreach ($rules as $rule => $atomizer) { $ok = preg_match($rule, $file); if ($ok === false) { throw new Exception( pht("Rule '%s' is not a valid regular expression.", $rule)); } if ($ok) { $atomizers[$file] = $atomizer; continue; } } } return $atomizers; } private function getRules() { return $this->getConfig('rules', array( '/\\.diviner$/' => 'DivinerArticleAtomizer', '/\\.php$/' => 'DivinerPHPAtomizer', )); } private function getExclude() { $exclude = (array)$this->getConfig('exclude', array()); return $exclude; } private function findFilesInProject() { $raw_hashes = id(new FileFinder($this->getConfig('root'))) ->excludePath('*/.*') ->withType('f') ->setGenerateChecksums(true) ->find(); $version = $this->getDivinerAtomWorldVersion(); $file_hashes = array(); foreach ($raw_hashes as $file => $md5_hash) { $rel_file = Filesystem::readablePath($file, $this->getConfig('root')); // We want the hash to change if the file moves or Diviner gets updated, // not just if the file content changes. Derive a hash from everything // we care about. $file_hashes[$rel_file] = md5("{$rel_file}\0{$md5_hash}\0{$version}").'F'; } return $file_hashes; } private function deleteDeadAtoms(array $file_hashes) { $atom_cache = $this->getAtomCache(); $hash_to_file = array_flip($file_hashes); foreach ($atom_cache->getFileHashMap() as $hash => $atom) { if (empty($hash_to_file[$hash])) { $atom_cache->deleteFileHash($hash); } } } private function getFilesToAtomize(array $file_hashes) { $atom_cache = $this->getAtomCache(); $atomize = array(); foreach ($file_hashes as $file => $hash) { if (!$atom_cache->fileHashExists($hash)) { $atomize[] = $file; } } return $atomize; } private function buildAtomizerFutures(array $file_atomizers) { $atomizers = array(); foreach ($file_atomizers as $file => $atomizer) { $atomizers[$atomizer][] = $file; } $root = dirname(phutil_get_library_root('phabricator')); $config_root = $this->getConfig('root'); $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($file_atomizers)); $futures = array(); foreach ($atomizers as $class => $files) { foreach (array_chunk($files, 32) as $chunk) { $future = new ExecFuture( '%s atomize --ugly --book %s --atomizer %s -- %Ls', $root.'/bin/diviner', $this->getBookConfigPath(), $class, $chunk); $future->setCWD($config_root); $futures[] = $future; $bar->update(count($chunk)); } } $bar->done(); return $futures; } private function resolveAtomizerFutures(array $futures, array $file_hashes) { assert_instances_of($futures, 'Future'); $atom_cache = $this->getAtomCache(); $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($futures)); $futures = id(new FutureIterator($futures)) ->limit(4); foreach ($futures as $key => $future) { try { $atoms = $future->resolveJSON(); foreach ($atoms as $atom) { if ($atom['type'] == DivinerAtom::TYPE_FILE) { $file_hash = $file_hashes[$atom['file']]; $atom_cache->addFileHash($file_hash, $atom['hash']); } $atom_cache->addAtom($atom); } } catch (Exception $e) { phlog($e); } $bar->update(1); } $bar->done(); } /** * Get a global version number, which changes whenever any atom or atomizer * implementation changes in a way which is not backward-compatible. */ private function getDivinerAtomWorldVersion() { $version = array(); $version['atom'] = DivinerAtom::getAtomSerializationVersion(); $version['rules'] = $this->getRules(); $atomizers = id(new PhutilSymbolLoader()) ->setAncestorClass('DivinerAtomizer') ->setConcreteOnly(true) ->selectAndLoadSymbols(); $atomizer_versions = array(); foreach ($atomizers as $atomizer) { $atomizer_versions[$atomizer['name']] = call_user_func( array( $atomizer['name'], 'getAtomizerVersion', )); } ksort($atomizer_versions); $version['atomizers'] = $atomizer_versions; return md5(serialize($version)); } /* -( Graph Cache )-------------------------------------------------------- */ private function buildGraphCache() { $this->log(pht('BUILDING GRAPH CACHE')); $atom_cache = $this->getAtomCache(); $symbol_map = $atom_cache->getSymbolMap(); $atoms = $atom_cache->getAtomMap(); $dirty_symbols = array(); $dirty_nhashes = array(); $del_atoms = array_diff_key($symbol_map, $atoms); $this->log( pht( 'Found %s obsolete atom(s) in graph.', new PhutilNumber(count($del_atoms)))); foreach ($del_atoms as $nhash => $shash) { $atom_cache->deleteSymbol($nhash); $dirty_symbols[$shash] = true; $atom_cache->deleteEdges($nhash); $atom_cache->deleteGraph($nhash); } $new_atoms = array_diff_key($atoms, $symbol_map); $this->log( pht( 'Found %s new atom(s) in graph.', new PhutilNumber(count($new_atoms)))); foreach ($new_atoms as $nhash => $ignored) { $shash = $this->computeSymbolHash($nhash); $atom_cache->addSymbol($nhash, $shash); $dirty_symbols[$shash] = true; $atom_cache->addEdges($nhash, $this->getEdges($nhash)); $dirty_nhashes[$nhash] = true; } $this->log(pht('Propagating changes through the graph.')); // Find all the nodes which point at a dirty node, and dirty them. Then // find all the nodes which point at those nodes and dirty them, and so // on. (This is slightly overkill since we probably don't need to propagate // dirtiness across documentation "links" between symbols, but we do want // to propagate it across "extends", and we suffer only a little bit of // collateral damage by over-dirtying as long as the documentation isn't // too well-connected.) $symbol_stack = array_keys($dirty_symbols); while ($symbol_stack) { $symbol_hash = array_pop($symbol_stack); foreach ($atom_cache->getEdgesWithDestination($symbol_hash) as $edge) { $dirty_nhashes[$edge] = true; $src_hash = $this->computeSymbolHash($edge); if (empty($dirty_symbols[$src_hash])) { $dirty_symbols[$src_hash] = true; $symbol_stack[] = $src_hash; } } } $this->log( pht( 'Found %s affected atoms.', new PhutilNumber(count($dirty_nhashes)))); foreach ($dirty_nhashes as $nhash => $ignored) { $atom_cache->addGraph($nhash, $this->computeGraphHash($nhash)); } $this->log(pht('Writing graph cache.')); $atom_cache->saveGraph(); $atom_cache->saveEdges(); $atom_cache->saveSymbols(); $this->log(pht('Done.')."\n"); } private function computeSymbolHash($node_hash) { $atom_cache = $this->getAtomCache(); $atom = $atom_cache->getAtom($node_hash); if (!$atom) { throw new Exception( pht("No such atom with node hash '%s'!", $node_hash)); } $ref = DivinerAtomRef::newFromDictionary($atom['ref']); return $ref->toHash(); } private function getEdges($node_hash) { $atom_cache = $this->getAtomCache(); $atom = $atom_cache->getAtom($node_hash); $refs = array(); // Make the atom depend on its own symbol, so that all atoms with the same // symbol are dirtied (e.g., if a codebase defines the function `f()` // several times, all of them should be dirtied when one is dirtied). $refs[DivinerAtomRef::newFromDictionary($atom)->toHash()] = true; foreach (array_merge($atom['extends'], $atom['links']) as $ref_dict) { $ref = DivinerAtomRef::newFromDictionary($ref_dict); if ($ref->getBook() == $atom['book']) { $refs[$ref->toHash()] = true; } } return array_keys($refs); } private function computeGraphHash($node_hash) { $atom_cache = $this->getAtomCache(); $atom = $atom_cache->getAtom($node_hash); $edges = $this->getEdges($node_hash); sort($edges); $inputs = array( 'atomHash' => $atom['hash'], 'edges' => $edges, ); return md5(serialize($inputs)).'G'; } private function publishDocumentation($clean, DivinerPublisher $publisher) { $atom_cache = $this->getAtomCache(); $graph_map = $atom_cache->getGraphMap(); $this->log(pht('PUBLISHING DOCUMENTATION')); $publisher ->setDropCaches($clean) ->setConfig($this->getAllConfig()) ->setAtomCache($atom_cache) ->setRenderer(new DivinerDefaultRenderer()) ->publishAtoms(array_values($graph_map)); $this->log(pht('Done.')); } } diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php index 13dd10a878..12865a0e59 100644 --- a/src/applications/repository/storage/PhabricatorRepository.php +++ b/src/applications/repository/storage/PhabricatorRepository.php @@ -1,1920 +1,1938 @@ setViewer($actor) ->withClasses(array('PhabricatorDiffusionApplication')) ->executeOne(); $view_policy = $app->getPolicy(DiffusionDefaultViewCapability::CAPABILITY); $edit_policy = $app->getPolicy(DiffusionDefaultEditCapability::CAPABILITY); $push_policy = $app->getPolicy(DiffusionDefaultPushCapability::CAPABILITY); $repository = id(new PhabricatorRepository()) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setPushPolicy($push_policy); // Put the repository in "Importing" mode until we finish // parsing it. $repository->setDetail('importing', true); return $repository; } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'details' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort255', 'callsign' => 'sort32', 'versionControlSystem' => 'text32', 'uuid' => 'text64?', 'pushPolicy' => 'policy', 'credentialPHID' => 'phid?', 'almanacServicePHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'callsign' => array( 'columns' => array('callsign'), 'unique' => true, ), 'key_name' => array( 'columns' => array('name(128)'), ), 'key_vcs' => array( 'columns' => array('versionControlSystem'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorRepositoryRepositoryPHIDType::TYPECONST); } public function toDictionary() { return array( 'id' => $this->getID(), 'name' => $this->getName(), 'phid' => $this->getPHID(), 'callsign' => $this->getCallsign(), 'monogram' => $this->getMonogram(), 'vcs' => $this->getVersionControlSystem(), 'uri' => PhabricatorEnv::getProductionURI($this->getURI()), 'remoteURI' => (string)$this->getRemoteURI(), 'description' => $this->getDetail('description'), 'isActive' => $this->isTracked(), 'isHosted' => $this->isHosted(), 'isImporting' => $this->isImporting(), 'encoding' => $this->getDetail('encoding'), 'staging' => array( 'supported' => $this->supportsStaging(), 'prefix' => 'phabricator', 'uri' => $this->getStagingURI(), ), ); } public function getMonogram() { return 'r'.$this->getCallsign(); } public function getDetail($key, $default = null) { return idx($this->details, $key, $default); } public function getHumanReadableDetail($key, $default = null) { $value = $this->getDetail($key, $default); switch ($key) { case 'branch-filter': case 'close-commits-filter': $value = array_keys($value); $value = implode(', ', $value); break; } return $value; } public function setDetail($key, $value) { $this->details[$key] = $value; return $this; } public function attachCommitCount($count) { $this->commitCount = $count; return $this; } public function getCommitCount() { return $this->assertAttached($this->commitCount); } public function attachMostRecentCommit( PhabricatorRepositoryCommit $commit = null) { $this->mostRecentCommit = $commit; return $this; } public function getMostRecentCommit() { return $this->assertAttached($this->mostRecentCommit); } public function getDiffusionBrowseURIForPath( PhabricatorUser $user, $path, $line = null, $branch = null) { $drequest = DiffusionRequest::newFromDictionary( array( 'user' => $user, 'repository' => $this, 'path' => $path, 'branch' => $branch, )); return $drequest->generateURI( array( 'action' => 'browse', 'line' => $line, )); } public function getLocalPath() { return $this->getDetail('local-path'); } public function getSubversionBaseURI($commit = null) { $subpath = $this->getDetail('svn-subpath'); if (!strlen($subpath)) { $subpath = null; } return $this->getSubversionPathURI($subpath, $commit); } public function getSubversionPathURI($path = null, $commit = null) { $vcs = $this->getVersionControlSystem(); if ($vcs != PhabricatorRepositoryType::REPOSITORY_TYPE_SVN) { throw new Exception(pht('Not a subversion repository!')); } if ($this->isHosted()) { $uri = 'file://'.$this->getLocalPath(); } else { $uri = $this->getDetail('remote-uri'); } $uri = rtrim($uri, '/'); if (strlen($path)) { $path = rawurlencode($path); $path = str_replace('%2F', '/', $path); $uri = $uri.'/'.ltrim($path, '/'); } if ($path !== null || $commit !== null) { $uri .= '@'; } if ($commit !== null) { $uri .= $commit; } return $uri; } public function attachProjectPHIDs(array $project_phids) { $this->projectPHIDs = $project_phids; return $this; } public function getProjectPHIDs() { return $this->assertAttached($this->projectPHIDs); } /** * Get the name of the directory this repository should clone or checkout * into. For example, if the repository name is "Example Repository", a * reasonable name might be "example-repository". This is used to help users * get reasonable results when cloning repositories, since they generally do * not want to clone into directories called "X/" or "Example Repository/". * * @return string */ public function getCloneName() { $name = $this->getDetail('clone-name'); // Make some reasonable effort to produce reasonable default directory // names from repository names. if (!strlen($name)) { $name = $this->getName(); $name = phutil_utf8_strtolower($name); $name = preg_replace('@[/ -:]+@', '-', $name); $name = trim($name, '-'); if (!strlen($name)) { $name = $this->getCallsign(); } } return $name; } /* -( Remote Command Execution )------------------------------------------- */ public function execRemoteCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newRemoteCommandFuture($args)->resolve(); } public function execxRemoteCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newRemoteCommandFuture($args)->resolvex(); } public function getRemoteCommandFuture($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newRemoteCommandFuture($args); } public function passthruRemoteCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newRemoteCommandPassthru($args)->execute(); } private function newRemoteCommandFuture(array $argv) { $argv = $this->formatRemoteCommand($argv); $future = newv('ExecFuture', $argv); $future->setEnv($this->getRemoteCommandEnvironment()); return $future; } private function newRemoteCommandPassthru(array $argv) { $argv = $this->formatRemoteCommand($argv); $passthru = newv('PhutilExecPassthru', $argv); $passthru->setEnv($this->getRemoteCommandEnvironment()); return $passthru; } /* -( Local Command Execution )-------------------------------------------- */ public function execLocalCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newLocalCommandFuture($args)->resolve(); } public function execxLocalCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newLocalCommandFuture($args)->resolvex(); } public function getLocalCommandFuture($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newLocalCommandFuture($args); } public function passthruLocalCommand($pattern /* , $arg, ... */) { $args = func_get_args(); return $this->newLocalCommandPassthru($args)->execute(); } private function newLocalCommandFuture(array $argv) { $this->assertLocalExists(); $argv = $this->formatLocalCommand($argv); $future = newv('ExecFuture', $argv); $future->setEnv($this->getLocalCommandEnvironment()); if ($this->usesLocalWorkingCopy()) { $future->setCWD($this->getLocalPath()); } return $future; } private function newLocalCommandPassthru(array $argv) { $this->assertLocalExists(); $argv = $this->formatLocalCommand($argv); $future = newv('PhutilExecPassthru', $argv); $future->setEnv($this->getLocalCommandEnvironment()); if ($this->usesLocalWorkingCopy()) { $future->setCWD($this->getLocalPath()); } return $future; } /* -( Command Infrastructure )--------------------------------------------- */ private function getSSHWrapper() { $root = dirname(phutil_get_library_root('phabricator')); return $root.'/bin/ssh-connect'; } private function getCommonCommandEnvironment() { $env = array( // NOTE: Force the language to "en_US.UTF-8", which overrides locale // settings. This makes stuff print in English instead of, e.g., French, // so we can parse the output of some commands, error messages, etc. 'LANG' => 'en_US.UTF-8', // Propagate PHABRICATOR_ENV explicitly. For discussion, see T4155. 'PHABRICATOR_ENV' => PhabricatorEnv::getSelectedEnvironmentName(), ); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: // NOTE: See T2965. Some time after Git 1.7.5.4, Git started fataling if // it can not read $HOME. For many users, $HOME points at /root (this // seems to be a default result of Apache setup). Instead, explicitly // point $HOME at a readable, empty directory so that Git looks for the // config file it's after, fails to locate it, and moves on. This is // really silly, but seems like the least damaging approach to // mitigating the issue. $root = dirname(phutil_get_library_root('phabricator')); $env['HOME'] = $root.'/support/empty/'; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: // NOTE: This overrides certain configuration, extensions, and settings // which make Mercurial commands do random unusual things. $env['HGPLAIN'] = 1; break; default: throw new Exception(pht('Unrecognized version control system.')); } return $env; } private function getLocalCommandEnvironment() { return $this->getCommonCommandEnvironment(); } private function getRemoteCommandEnvironment() { $env = $this->getCommonCommandEnvironment(); if ($this->shouldUseSSH()) { // NOTE: This is read by `bin/ssh-connect`, and tells it which credentials // to use. $env['PHABRICATOR_CREDENTIAL'] = $this->getCredentialPHID(); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // Force SVN to use `bin/ssh-connect`. $env['SVN_SSH'] = $this->getSSHWrapper(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: // Force Git to use `bin/ssh-connect`. $env['GIT_SSH'] = $this->getSSHWrapper(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: // We force Mercurial through `bin/ssh-connect` too, but it uses a // command-line flag instead of an environmental variable. break; default: throw new Exception(pht('Unrecognized version control system.')); } } return $env; } private function formatRemoteCommand(array $args) { $pattern = $args[0]; $args = array_slice($args, 1); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: if ($this->shouldUseHTTP() || $this->shouldUseSVNProtocol()) { $flags = array(); $flag_args = array(); $flags[] = '--non-interactive'; $flags[] = '--no-auth-cache'; if ($this->shouldUseHTTP()) { $flags[] = '--trust-server-cert'; } $credential_phid = $this->getCredentialPHID(); if ($credential_phid) { $key = PassphrasePasswordKey::loadFromPHID( $credential_phid, PhabricatorUser::getOmnipotentUser()); $flags[] = '--username %P'; $flags[] = '--password %P'; $flag_args[] = $key->getUsernameEnvelope(); $flag_args[] = $key->getPasswordEnvelope(); } $flags = implode(' ', $flags); $pattern = "svn {$flags} {$pattern}"; $args = array_mergev(array($flag_args, $args)); } else { $pattern = "svn --non-interactive {$pattern}"; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $pattern = "git {$pattern}"; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: if ($this->shouldUseSSH()) { $pattern = "hg --config ui.ssh=%s {$pattern}"; array_unshift( $args, $this->getSSHWrapper()); } else { $pattern = "hg {$pattern}"; } break; default: throw new Exception(pht('Unrecognized version control system.')); } array_unshift($args, $pattern); return $args; } private function formatLocalCommand(array $args) { $pattern = $args[0]; $args = array_slice($args, 1); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $pattern = "svn --non-interactive {$pattern}"; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $pattern = "git {$pattern}"; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $pattern = "hg {$pattern}"; break; default: throw new Exception(pht('Unrecognized version control system.')); } array_unshift($args, $pattern); return $args; } /** * Sanitize output of an `hg` command invoked with the `--debug` flag to make * it usable. * * @param string Output from `hg --debug ...` * @return string Usable output. */ public static function filterMercurialDebugOutput($stdout) { // When hg commands are run with `--debug` and some config file isn't // trusted, Mercurial prints out a warning to stdout, twice, after Feb 2011. // // http://selenic.com/pipermail/mercurial-devel/2011-February/028541.html // // After Jan 2015, it may also fail to write to a revision branch cache. $ignore = array( 'ignoring untrusted configuration option', "couldn't write revision branch cache:", ); foreach ($ignore as $key => $pattern) { $ignore[$key] = preg_quote($pattern, '/'); } $ignore = '('.implode('|', $ignore).')'; $lines = preg_split('/(?<=\n)/', $stdout); $regex = '/'.$ignore.'.*\n$/'; foreach ($lines as $key => $line) { $lines[$key] = preg_replace($regex, '', $line); } return implode('', $lines); } public function getURI() { return '/diffusion/'.$this->getCallsign().'/'; } public function getNormalizedPath() { $uri = (string)$this->getCloneURIObject(); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $normalized_uri = new PhabricatorRepositoryURINormalizer( PhabricatorRepositoryURINormalizer::TYPE_GIT, $uri); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $normalized_uri = new PhabricatorRepositoryURINormalizer( PhabricatorRepositoryURINormalizer::TYPE_SVN, $uri); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $normalized_uri = new PhabricatorRepositoryURINormalizer( PhabricatorRepositoryURINormalizer::TYPE_MERCURIAL, $uri); break; default: throw new Exception(pht('Unrecognized version control system.')); } return $normalized_uri->getNormalizedPath(); } public function isTracked() { return $this->getDetail('tracking-enabled', false); } public function getDefaultBranch() { $default = $this->getDetail('default-branch'); if (strlen($default)) { return $default; } $default_branches = array( PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'master', PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL => 'default', ); return idx($default_branches, $this->getVersionControlSystem()); } public function getDefaultArcanistBranch() { return coalesce($this->getDefaultBranch(), 'svn'); } private function isBranchInFilter($branch, $filter_key) { $vcs = $this->getVersionControlSystem(); $is_git = ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT); $use_filter = ($is_git); if (!$use_filter) { // If this VCS doesn't use filters, pass everything through. return true; } $filter = $this->getDetail($filter_key, array()); // If there's no filter set, let everything through. if (!$filter) { return true; } // If this branch isn't literally named `regexp(...)`, and it's in the // filter list, let it through. if (isset($filter[$branch])) { if (self::extractBranchRegexp($branch) === null) { return true; } } // If the branch matches a regexp, let it through. foreach ($filter as $pattern => $ignored) { $regexp = self::extractBranchRegexp($pattern); if ($regexp !== null) { if (preg_match($regexp, $branch)) { return true; } } } // Nothing matched, so filter this branch out. return false; } public static function extractBranchRegexp($pattern) { $matches = null; if (preg_match('/^regexp\\((.*)\\)\z/', $pattern, $matches)) { return $matches[1]; } return null; } public function shouldTrackBranch($branch) { return $this->isBranchInFilter($branch, 'branch-filter'); } public function formatCommitName($commit_identifier) { $vcs = $this->getVersionControlSystem(); $type_git = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; $type_hg = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; $is_git = ($vcs == $type_git); $is_hg = ($vcs == $type_hg); if ($is_git || $is_hg) { $short_identifier = substr($commit_identifier, 0, 12); } else { $short_identifier = $commit_identifier; } return 'r'.$this->getCallsign().$short_identifier; } public function isImporting() { return (bool)$this->getDetail('importing', false); } /** * Should this repository publish feed, notifications, audits, and email? * * We do not publish information about repositories during initial import, * or if the repository has been set not to publish. */ public function shouldPublish() { if ($this->isImporting()) { return false; } if ($this->getDetail('disable-herald')) { return false; } return true; } /* -( Autoclose )---------------------------------------------------------- */ /** * Determine if autoclose is active for a branch. * * For more details about why, use @{method:shouldSkipAutocloseBranch}. * * @param string Branch name to check. * @return bool True if autoclose is active for the branch. * @task autoclose */ public function shouldAutocloseBranch($branch) { return ($this->shouldSkipAutocloseBranch($branch) === null); } /** * Determine if autoclose is active for a commit. * * For more details about why, use @{method:shouldSkipAutocloseCommit}. * * @param PhabricatorRepositoryCommit Commit to check. * @return bool True if autoclose is active for the commit. * @task autoclose */ public function shouldAutocloseCommit(PhabricatorRepositoryCommit $commit) { return ($this->shouldSkipAutocloseCommit($commit) === null); } /** * Determine why autoclose should be skipped for a branch. * * This method gives a detailed reason why autoclose will be skipped. To * perform a simple test, use @{method:shouldAutocloseBranch}. * * @param string Branch name to check. * @return const|null Constant identifying reason to skip this branch, or null * if autoclose is active. * @task autoclose */ public function shouldSkipAutocloseBranch($branch) { $all_reason = $this->shouldSkipAllAutoclose(); if ($all_reason) { return $all_reason; } if (!$this->shouldTrackBranch($branch)) { return self::BECAUSE_BRANCH_UNTRACKED; } if (!$this->isBranchInFilter($branch, 'close-commits-filter')) { return self::BECAUSE_BRANCH_NOT_AUTOCLOSE; } return null; } /** * Determine why autoclose should be skipped for a commit. * * This method gives a detailed reason why autoclose will be skipped. To * perform a simple test, use @{method:shouldAutocloseCommit}. * * @param PhabricatorRepositoryCommit Commit to check. * @return const|null Constant identifying reason to skip this commit, or null * if autoclose is active. * @task autoclose */ public function shouldSkipAutocloseCommit( PhabricatorRepositoryCommit $commit) { $all_reason = $this->shouldSkipAllAutoclose(); if ($all_reason) { return $all_reason; } switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return null; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: break; default: throw new Exception(pht('Unrecognized version control system.')); } $closeable_flag = PhabricatorRepositoryCommit::IMPORTED_CLOSEABLE; if (!$commit->isPartiallyImported($closeable_flag)) { return self::BECAUSE_NOT_ON_AUTOCLOSE_BRANCH; } return null; } /** * Determine why all autoclose operations should be skipped for this * repository. * * @return const|null Constant identifying reason to skip all autoclose * operations, or null if autoclose operations are not blocked at the * repository level. * @task autoclose */ private function shouldSkipAllAutoclose() { if ($this->isImporting()) { return self::BECAUSE_REPOSITORY_IMPORTING; } if ($this->getDetail('disable-autoclose', false)) { return self::BECAUSE_AUTOCLOSE_DISABLED; } return null; } /* -( Repository URI Management )------------------------------------------ */ /** * Get the remote URI for this repository. * * @return string * @task uri */ public function getRemoteURI() { return (string)$this->getRemoteURIObject(); } /** * Get the remote URI for this repository, including credentials if they're * used by this repository. * * @return PhutilOpaqueEnvelope URI, possibly including credentials. * @task uri */ public function getRemoteURIEnvelope() { $uri = $this->getRemoteURIObject(); $remote_protocol = $this->getRemoteProtocol(); if ($remote_protocol == 'http' || $remote_protocol == 'https') { // For SVN, we use `--username` and `--password` flags separately, so // don't add any credentials here. if (!$this->isSVN()) { $credential_phid = $this->getCredentialPHID(); if ($credential_phid) { $key = PassphrasePasswordKey::loadFromPHID( $credential_phid, PhabricatorUser::getOmnipotentUser()); $uri->setUser($key->getUsernameEnvelope()->openEnvelope()); $uri->setPass($key->getPasswordEnvelope()->openEnvelope()); } } } return new PhutilOpaqueEnvelope((string)$uri); } /** * Get the clone (or checkout) URI for this repository, without authentication * information. * * @return string Repository URI. * @task uri */ public function getPublicCloneURI() { $uri = $this->getCloneURIObject(); // Make sure we don't leak anything if this repo is using HTTP Basic Auth // with the credentials in the URI or something zany like that. // If repository is not accessed over SSH we remove both username and // password. if (!$this->isHosted()) { if (!$this->shouldUseSSH()) { $uri->setUser(null); // This might be a Git URI or a normal URI. If it's Git, there's no // password support. if ($uri instanceof PhutilURI) { $uri->setPass(null); } } } return (string)$uri; } /** * Get the protocol for the repository's remote. * * @return string Protocol, like "ssh" or "git". * @task uri */ public function getRemoteProtocol() { $uri = $this->getRemoteURIObject(); if ($uri instanceof PhutilGitURI) { return 'ssh'; } else { return $uri->getProtocol(); } } /** * Get a parsed object representation of the repository's remote URI. This * may be a normal URI (returned as a @{class@libphutil:PhutilURI}) or a git * URI (returned as a @{class@libphutil:PhutilGitURI}). * * @return wild A @{class@libphutil:PhutilURI} or * @{class@libphutil:PhutilGitURI}. * @task uri */ public function getRemoteURIObject() { $raw_uri = $this->getDetail('remote-uri'); if (!$raw_uri) { return new PhutilURI(''); } if (!strncmp($raw_uri, '/', 1)) { return new PhutilURI('file://'.$raw_uri); } $uri = new PhutilURI($raw_uri); if ($uri->getProtocol()) { return $uri; } $uri = new PhutilGitURI($raw_uri); if ($uri->getDomain()) { return $uri; } throw new Exception(pht("Remote URI '%s' could not be parsed!", $raw_uri)); } /** * Get the "best" clone/checkout URI for this repository, on any protocol. */ public function getCloneURIObject() { if (!$this->isHosted()) { if ($this->isSVN()) { // Make sure we pick up the "Import Only" path for Subversion, so // the user clones the repository starting at the correct path, not // from the root. $base_uri = $this->getSubversionBaseURI(); $base_uri = new PhutilURI($base_uri); $path = $base_uri->getPath(); if (!$path) { $path = '/'; } // If the trailing "@" is not required to escape the URI, strip it for // readability. if (!preg_match('/@.*@/', $path)) { $path = rtrim($path, '@'); } $base_uri->setPath($path); return $base_uri; } else { return $this->getRemoteURIObject(); } } // Choose the best URI: pick a read/write URI over a URI which is not // read/write, and SSH over HTTP. $serve_ssh = $this->getServeOverSSH(); $serve_http = $this->getServeOverHTTP(); if ($serve_ssh === self::SERVE_READWRITE) { return $this->getSSHCloneURIObject(); } else if ($serve_http === self::SERVE_READWRITE) { return $this->getHTTPCloneURIObject(); } else if ($serve_ssh !== self::SERVE_OFF) { return $this->getSSHCloneURIObject(); } else if ($serve_http !== self::SERVE_OFF) { return $this->getHTTPCloneURIObject(); } else { return null; } } /** * Get the repository's SSH clone/checkout URI, if one exists. */ public function getSSHCloneURIObject() { if (!$this->isHosted()) { if ($this->shouldUseSSH()) { return $this->getRemoteURIObject(); } else { return null; } } $serve_ssh = $this->getServeOverSSH(); if ($serve_ssh === self::SERVE_OFF) { return null; } $uri = new PhutilURI(PhabricatorEnv::getProductionURI($this->getURI())); if ($this->isSVN()) { $uri->setProtocol('svn+ssh'); } else { $uri->setProtocol('ssh'); } if ($this->isGit()) { $uri->setPath($uri->getPath().$this->getCloneName().'.git'); } else if ($this->isHg()) { $uri->setPath($uri->getPath().$this->getCloneName().'/'); } $ssh_user = PhabricatorEnv::getEnvConfig('diffusion.ssh-user'); if ($ssh_user) { $uri->setUser($ssh_user); } $ssh_host = PhabricatorEnv::getEnvConfig('diffusion.ssh-host'); if (strlen($ssh_host)) { $uri->setDomain($ssh_host); } $uri->setPort(PhabricatorEnv::getEnvConfig('diffusion.ssh-port')); return $uri; } /** * Get the repository's HTTP clone/checkout URI, if one exists. */ public function getHTTPCloneURIObject() { if (!$this->isHosted()) { if ($this->shouldUseHTTP()) { return $this->getRemoteURIObject(); } else { return null; } } $serve_http = $this->getServeOverHTTP(); if ($serve_http === self::SERVE_OFF) { return null; } $uri = PhabricatorEnv::getProductionURI($this->getURI()); $uri = new PhutilURI($uri); if ($this->isGit()) { $uri->setPath($uri->getPath().$this->getCloneName().'.git'); } else if ($this->isHg()) { $uri->setPath($uri->getPath().$this->getCloneName().'/'); } return $uri; } /** * Determine if we should connect to the remote using SSH flags and * credentials. * * @return bool True to use the SSH protocol. * @task uri */ private function shouldUseSSH() { if ($this->isHosted()) { return false; } $protocol = $this->getRemoteProtocol(); if ($this->isSSHProtocol($protocol)) { return true; } return false; } /** * Determine if we should connect to the remote using HTTP flags and * credentials. * * @return bool True to use the HTTP protocol. * @task uri */ private function shouldUseHTTP() { if ($this->isHosted()) { return false; } $protocol = $this->getRemoteProtocol(); return ($protocol == 'http' || $protocol == 'https'); } /** * Determine if we should connect to the remote using SVN flags and * credentials. * * @return bool True to use the SVN protocol. * @task uri */ private function shouldUseSVNProtocol() { if ($this->isHosted()) { return false; } $protocol = $this->getRemoteProtocol(); return ($protocol == 'svn'); } /** * Determine if a protocol is SSH or SSH-like. * * @param string A protocol string, like "http" or "ssh". * @return bool True if the protocol is SSH-like. * @task uri */ private function isSSHProtocol($protocol) { return ($protocol == 'ssh' || $protocol == 'svn+ssh'); } public function delete() { $this->openTransaction(); $paths = id(new PhabricatorOwnersPath()) ->loadAllWhere('repositoryPHID = %s', $this->getPHID()); foreach ($paths as $path) { $path->delete(); } $projects = id(new PhabricatorRepositoryArcanistProject()) ->loadAllWhere('repositoryID = %d', $this->getID()); foreach ($projects as $project) { $project->delete(); } queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE repositoryPHID = %s', id(new PhabricatorRepositorySymbol())->getTableName(), $this->getPHID()); $commits = id(new PhabricatorRepositoryCommit()) ->loadAllWhere('repositoryID = %d', $this->getID()); foreach ($commits as $commit) { // note PhabricatorRepositoryAuditRequests and // PhabricatorRepositoryCommitData are deleted here too. $commit->delete(); } $mirrors = id(new PhabricatorRepositoryMirror()) ->loadAllWhere('repositoryPHID = %s', $this->getPHID()); foreach ($mirrors as $mirror) { $mirror->delete(); } $ref_cursors = id(new PhabricatorRepositoryRefCursor()) ->loadAllWhere('repositoryPHID = %s', $this->getPHID()); foreach ($ref_cursors as $cursor) { $cursor->delete(); } $conn_w = $this->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE repositoryID = %d', self::TABLE_FILESYSTEM, $this->getID()); queryfx( $conn_w, 'DELETE FROM %T WHERE repositoryID = %d', self::TABLE_PATHCHANGE, $this->getID()); queryfx( $conn_w, 'DELETE FROM %T WHERE repositoryID = %d', self::TABLE_SUMMARY, $this->getID()); $result = parent::delete(); $this->saveTransaction(); return $result; } public function isGit() { $vcs = $this->getVersionControlSystem(); return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT); } public function isSVN() { $vcs = $this->getVersionControlSystem(); return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_SVN); } public function isHg() { $vcs = $this->getVersionControlSystem(); return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL); } public function isHosted() { return (bool)$this->getDetail('hosting-enabled', false); } public function setHosted($enabled) { return $this->setDetail('hosting-enabled', $enabled); } public function getServeOverHTTP() { if ($this->isSVN()) { return self::SERVE_OFF; } $serve = $this->getDetail('serve-over-http', self::SERVE_OFF); return $this->normalizeServeConfigSetting($serve); } public function setServeOverHTTP($mode) { return $this->setDetail('serve-over-http', $mode); } public function getServeOverSSH() { $serve = $this->getDetail('serve-over-ssh', self::SERVE_OFF); return $this->normalizeServeConfigSetting($serve); } public function setServeOverSSH($mode) { return $this->setDetail('serve-over-ssh', $mode); } public static function getProtocolAvailabilityName($constant) { switch ($constant) { case self::SERVE_OFF: return pht('Off'); case self::SERVE_READONLY: return pht('Read Only'); case self::SERVE_READWRITE: return pht('Read/Write'); default: return pht('Unknown'); } } private function normalizeServeConfigSetting($value) { switch ($value) { case self::SERVE_OFF: case self::SERVE_READONLY: return $value; case self::SERVE_READWRITE: if ($this->isHosted()) { return self::SERVE_READWRITE; } else { return self::SERVE_READONLY; } default: return self::SERVE_OFF; } } /** * Raise more useful errors when there are basic filesystem problems. */ private function assertLocalExists() { if (!$this->usesLocalWorkingCopy()) { return; } $local = $this->getLocalPath(); Filesystem::assertExists($local); Filesystem::assertIsDirectory($local); Filesystem::assertReadable($local); } /** * Determine if the working copy is bare or not. In Git, this corresponds * to `--bare`. In Mercurial, `--noupdate`. */ public function isWorkingCopyBare() { switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return false; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $local = $this->getLocalPath(); if (Filesystem::pathExists($local.'/.git')) { return false; } else { return true; } } } public function usesLocalWorkingCopy() { switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: return $this->isHosted(); case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return true; } } public function getHookDirectories() { $directories = array(); if (!$this->isHosted()) { return $directories; } $root = $this->getLocalPath(); switch ($this->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: if ($this->isWorkingCopyBare()) { $directories[] = $root.'/hooks/pre-receive-phabricator.d/'; } else { $directories[] = $root.'/.git/hooks/pre-receive-phabricator.d/'; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $directories[] = $root.'/hooks/pre-commit-phabricator.d/'; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: // NOTE: We don't support custom Mercurial hooks for now because they're // messy and we can't easily just drop a `hooks.d/` directory next to // the hooks. break; } return $directories; } public function canDestroyWorkingCopy() { if ($this->isHosted()) { // Never destroy hosted working copies. return false; } $default_path = PhabricatorEnv::getEnvConfig( 'repository.default-local-path'); return Filesystem::isDescendant($this->getLocalPath(), $default_path); } public function canUsePathTree() { return !$this->isSVN(); } public function canMirror() { if ($this->isGit() || $this->isHg()) { return true; } return false; } public function canAllowDangerousChanges() { if (!$this->isHosted()) { return false; } if ($this->isGit() || $this->isHg()) { return true; } return false; } public function shouldAllowDangerousChanges() { return (bool)$this->getDetail('allow-dangerous-changes'); } public function writeStatusMessage( $status_type, $status_code, array $parameters = array()) { $table = new PhabricatorRepositoryStatusMessage(); $conn_w = $table->establishConnection('w'); $table_name = $table->getTableName(); if ($status_code === null) { queryfx( $conn_w, 'DELETE FROM %T WHERE repositoryID = %d AND statusType = %s', $table_name, $this->getID(), $status_type); } else { queryfx( $conn_w, 'INSERT INTO %T (repositoryID, statusType, statusCode, parameters, epoch) VALUES (%d, %s, %s, %s, %d) ON DUPLICATE KEY UPDATE statusCode = VALUES(statusCode), parameters = VALUES(parameters), epoch = VALUES(epoch)', $table_name, $this->getID(), $status_type, $status_code, json_encode($parameters), time()); } return $this; } public static function getRemoteURIProtocol($raw_uri) { $uri = new PhutilURI($raw_uri); if ($uri->getProtocol()) { return strtolower($uri->getProtocol()); } $git_uri = new PhutilGitURI($raw_uri); if (strlen($git_uri->getDomain()) && strlen($git_uri->getPath())) { return 'ssh'; } return null; } public static function assertValidRemoteURI($uri) { if (trim($uri) != $uri) { throw new Exception( pht('The remote URI has leading or trailing whitespace.')); } $protocol = self::getRemoteURIProtocol($uri); // Catch confusion between Git/SCP-style URIs and normal URIs. See T3619 // for discussion. This is usually a user adding "ssh://" to an implicit // SSH Git URI. if ($protocol == 'ssh') { if (preg_match('(^[^:@]+://[^/:]+:[^\d])', $uri)) { throw new Exception( pht( "The remote URI is not formatted correctly. Remote URIs ". "with an explicit protocol should be in the form ". "'%s', not '%s'. The '%s' syntax is only valid in SCP-style URIs.", 'proto://domain/path', 'proto://domain:/path', ':/path')); } } switch ($protocol) { case 'ssh': case 'http': case 'https': case 'git': case 'svn': case 'svn+ssh': break; default: // NOTE: We're explicitly rejecting 'file://' because it can be // used to clone from the working copy of another repository on disk // that you don't normally have permission to access. throw new Exception( pht( "The URI protocol is unrecognized. It should begin ". "'%s', '%s', '%s', '%s', '%s', '%s', or be in the form '%s'.", 'ssh://', 'http://', 'https://', 'git://', 'svn://', 'svn+ssh://', 'git@domain.com:path')); } return true; } /** * Load the pull frequency for this repository, based on the time since the * last activity. * * We pull rarely used repositories less frequently. This finds the most * recent commit which is older than the current time (which prevents us from * spinning on repositories with a silly commit post-dated to some time in * 2037). We adjust the pull frequency based on when the most recent commit * occurred. * * @param int The minimum update interval to use, in seconds. * @return int Repository update interval, in seconds. */ public function loadUpdateInterval($minimum = 15) { // If a repository is still importing, always pull it as frequently as // possible. This prevents us from hanging for a long time at 99.9% when // importing an inactive repository. if ($this->isImporting()) { return $minimum; } $window_start = (PhabricatorTime::getNow() + $minimum); $table = id(new PhabricatorRepositoryCommit()); $last_commit = queryfx_one( $table->establishConnection('r'), 'SELECT epoch FROM %T WHERE repositoryID = %d AND epoch <= %d ORDER BY epoch DESC LIMIT 1', $table->getTableName(), $this->getID(), $window_start); if ($last_commit) { $time_since_commit = ($window_start - $last_commit['epoch']); $last_few_days = phutil_units('3 days in seconds'); if ($time_since_commit <= $last_few_days) { // For repositories with activity in the recent past, we wait one // extra second for every 10 minutes since the last commit. This // shorter backoff is intended to handle weekends and other short // breaks from development. $smart_wait = ($time_since_commit / 600); } else { // For repositories without recent activity, we wait one extra second // for every 4 minutes since the last commit. This longer backoff // handles rarely used repositories, up to the maximum. $smart_wait = ($time_since_commit / 240); } // We'll never wait more than 6 hours to pull a repository. $longest_wait = phutil_units('6 hours in seconds'); $smart_wait = min($smart_wait, $longest_wait); $smart_wait = max($minimum, $smart_wait); } else { $smart_wait = $minimum; } return $smart_wait; } /** * Retrieve the sevice URI for the device hosting this repository. * * See @{method:newConduitClient} for a general discussion of interacting * with repository services. This method provides lower-level resolution of * services, returning raw URIs. * * @param PhabricatorUser Viewing user. * @param bool `true` to throw if a remote URI would be returned. * @param list List of allowable protocols. * @return string|null URI, or `null` for local repositories. */ public function getAlmanacServiceURI( PhabricatorUser $viewer, $never_proxy, array $protocols) { $service_phid = $this->getAlmanacServicePHID(); if (!$service_phid) { // No service, so this is a local repository. return null; } $service = id(new AlmanacServiceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($service_phid)) ->needBindings(true) ->executeOne(); if (!$service) { throw new Exception( pht( 'The Almanac service for this repository is invalid or could not '. 'be loaded.')); } $service_type = $service->getServiceType(); if (!($service_type instanceof AlmanacClusterRepositoryServiceType)) { throw new Exception( pht( 'The Almanac service for this repository does not have the correct '. 'service type.')); } $bindings = $service->getBindings(); if (!$bindings) { throw new Exception( pht( 'The Almanac service for this repository is not bound to any '. 'interfaces.')); } $local_device = AlmanacKeys::getDeviceID(); if ($never_proxy && !$local_device) { throw new Exception( pht( 'Unable to handle proxied service request. This device is not '. 'registered, so it can not identify local services. Register '. 'this device before sending requests here.')); } $protocol_map = array_fuse($protocols); $uris = array(); foreach ($bindings as $binding) { $iface = $binding->getInterface(); // If we're never proxying this and it's locally satisfiable, return // `null` to tell the caller to handle it locally. If we're allowed to // proxy, we skip this check and may proxy the request to ourselves. // (That proxied request will end up here with proxying forbidden, // return `null`, and then the request will actually run.) if ($local_device && $never_proxy) { if ($iface->getDevice()->getName() == $local_device) { return null; } } $protocol = $binding->getAlmanacPropertyValue('protocol'); if ($protocol === null) { $protocol = 'https'; } if (empty($protocol_map[$protocol])) { continue; } $uris[] = $protocol.'://'.$iface->renderDisplayAddress().'/'; } if (!$uris) { throw new Exception( pht( 'The Almanac service for this repository is not bound to any '. 'interfaces which support the required protocols (%s).', implode(', ', $protocols))); } if ($never_proxy) { throw new Exception( pht( 'Refusing to proxy a repository request from a cluster host. '. 'Cluster hosts must correctly route their intracluster requests.')); } shuffle($uris); return head($uris); } /** * Build a new Conduit client in order to make a service call to this * repository. * * If the repository is hosted locally, this method may return `null`. The * caller should use `ConduitCall` or other local logic to complete the * request. * * By default, we will return a @{class:ConduitClient} for any repository with * a service, even if that service is on the current device. * * We do this because this configuration does not make very much sense in a * production context, but is very common in a test/development context * (where the developer's machine is both the web host and the repository * service). By proxying in development, we get more consistent behavior * between development and production, and don't have a major untested * codepath. * * The `$never_proxy` parameter can be used to prevent this local proxying. * If the flag is passed: * * - The method will return `null` (implying a local service call) * if the repository service is hosted on the current device. * - The method will throw if it would need to return a client. * * This is used to prevent loops in Conduit: the first request will proxy, * even in development, but the second request will be identified as a * cluster request and forced not to proxy. * * For lower-level service resolution, see @{method:getAlmanacServiceURI}. * * @param PhabricatorUser Viewing user. * @param bool `true` to throw if a client would be returned. * @return ConduitClient|null Client, or `null` for local repositories. */ public function newConduitClient( PhabricatorUser $viewer, $never_proxy = false) { $uri = $this->getAlmanacServiceURI( $viewer, $never_proxy, array( 'http', 'https', )); if ($uri === null) { return null; } $domain = id(new PhutilURI(PhabricatorEnv::getURI('/')))->getDomain(); $client = id(new ConduitClient($uri)) ->setHost($domain); if ($viewer->isOmnipotent()) { // If the caller is the omnipotent user (normally, a daemon), we will // sign the request with this host's asymmetric keypair. $public_path = AlmanacKeys::getKeyPath('device.pub'); try { $public_key = Filesystem::readFile($public_path); } catch (Exception $ex) { throw new PhutilAggregateException( pht( 'Unable to read device public key while attempting to make '. 'authenticated method call within the Phabricator cluster. '. 'Use `%s` to register keys for this device. Exception: %s', 'bin/almanac register', $ex->getMessage()), array($ex)); } $private_path = AlmanacKeys::getKeyPath('device.key'); try { $private_key = Filesystem::readFile($private_path); $private_key = new PhutilOpaqueEnvelope($private_key); } catch (Exception $ex) { throw new PhutilAggregateException( pht( 'Unable to read device private key while attempting to make '. 'authenticated method call within the Phabricator cluster. '. 'Use `%s` to register keys for this device. Exception: %s', 'bin/almanac register', $ex->getMessage()), array($ex)); } $client->setSigningKeys($public_key, $private_key); } else { // If the caller is a normal user, we generate or retrieve a cluster // API token. $token = PhabricatorConduitToken::loadClusterTokenForUser($viewer); if ($token) { $client->setConduitToken($token->getToken()); } } return $client; } /* -( Symbols )-------------------------------------------------------------*/ public function getSymbolSources() { return $this->getDetail('symbol-sources', array()); } public function getSymbolLanguages() { return $this->getDetail('symbol-languages', array()); } /* -( Staging )-------------------------------------------------------------*/ public function supportsStaging() { return $this->isGit(); } public function getStagingURI() { if (!$this->supportsStaging()) { return null; } return $this->getDetail('staging-uri', null); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorRepositoryEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorRepositoryTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, DiffusionPushCapability::CAPABILITY, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); case DiffusionPushCapability::CAPABILITY: return $this->getPushPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $user) { return false; } public function describeAutomaticCapability($capability) { return null; } /* -( PhabricatorMarkupInterface )----------------------------------------- */ public function getMarkupFieldKey($field) { $hash = PhabricatorHash::digestForIndex($this->getMarkupText($field)); return "repo:{$hash}"; } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newMarkupEngine(array()); } public function getMarkupText($field) { return $this->getDetail('description'); } public function didMarkupText( $field, $output, PhutilMarkupEngine $engine) { require_celerity_resource('phabricator-remarkup-css'); return phutil_tag( 'div', array( 'class' => 'phabricator-remarkup', ), $output); } public function shouldUseMarkupCache($field) { return true; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); - $this->delete(); + + $this->delete(); + + $books = id(new DivinerBookQuery()) + ->setViewer($engine->getViewer()) + ->withRepositoryPHIDs(array($this->getPHID())) + ->execute(); + foreach ($books as $book) { + $engine->destroyObject($book); + } + + $atoms = id(new DivinerAtomQuery()) + ->setViewer($engine->getViewer()) + ->withRepositoryPHIDs(array($this->getPHID())) + ->execute(); + foreach ($atoms as $atom) { + $engine->destroyObject($atom); + } + $this->saveTransaction(); } }