diff --git a/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php b/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php index 0d2f46d833..d1a45c51fb 100644 --- a/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php @@ -1,269 +1,270 @@ ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'resolves' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'resolved' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'fix' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'fixes' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'fixed' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'wontfix' => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, 'wontfixes' => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, 'wontfixed' => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, 'spite' => ManiphestTaskStatus::STATUS_CLOSED_SPITE, 'spites' => ManiphestTaskStatus::STATUS_CLOSED_SPITE, 'spited' => ManiphestTaskStatus::STATUS_CLOSED_SPITE, 'invalidate' => ManiphestTaskStatus::STATUS_CLOSED_INVALID, 'invaldiates' => ManiphestTaskStatus::STATUS_CLOSED_INVALID, 'invalidated' => ManiphestTaskStatus::STATUS_CLOSED_INVALID, 'close' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'closes' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'closed' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'ref' => null, 'refs' => null, 'references' => null, 'cf.' => null, ); $suffixes = array( 'as resolved' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'as fixed' => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, 'as wontfix' => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, 'as spite' => ManiphestTaskStatus::STATUS_CLOSED_SPITE, 'out of spite' => ManiphestTaskStatus::STATUS_CLOSED_SPITE, 'as invalid' => ManiphestTaskStatus::STATUS_CLOSED_INVALID, '' => null, ); $prefix_regex = array(); foreach ($prefixes as $prefix => $resolution) { $prefix_regex[] = preg_quote($prefix, '/'); } $prefix_regex = implode('|', $prefix_regex); $suffix_regex = array(); foreach ($suffixes as $suffix => $resolution) { $suffix_regex[] = preg_quote($suffix, '/'); } $suffix_regex = implode('|', $suffix_regex); $matches = null; preg_match_all( "/({$prefix_regex})\s+T(\d+)\s*({$suffix_regex})/i", $message, $matches, PREG_SET_ORDER); $tasks_statuses = array(); foreach ($matches as $set) { $prefix = strtolower($set[1]); $task_id = (int)$set[2]; $suffix = strtolower($set[3]); $status = idx($suffixes, $suffix); if (!$status) { $status = idx($prefixes, $prefix); } $tasks_statuses[$task_id] = $status; } return $tasks_statuses; } private function findDependentRevisions($message) { $dependents = array(); $matches = null; preg_match_all( '/\b(?i:depends\s+on):?\s+D(\d+(,\s+D\d++)*)\b/', $message, $matches); foreach ($matches[1] as $revisions) { foreach (preg_split('/,\s+D/', $revisions) as $id) { $dependents[$id] = $id; } } return $dependents; } public static function findRevertedCommits($message) { $reverts = array(); $matches = null; // NOTE: Git language is "This reverts commit X." // NOTE: Mercurial language is "Backed out changeset Y". $prefixes = array( 'revert' => true, 'reverts' => true, 'back\s*out' => true, 'backs\s*out' => true, 'backed\s*out' => true, 'undo' => true, 'undoes' => true, ); $optional = array( 'commit' => true, 'changeset' => true, 'rev' => true, 'revision' => true, 'change' => true, 'diff' => true, ); $pre_re = implode('|', array_keys($prefixes)); $opt_re = implode('|', array_keys($optional)); $matches = null; preg_match_all( '/\b(?i:'.$pre_re.')(?:\s+(?i:'.$opt_re.'))?([rA-Z0-9a-f,\s]+)\b/', $message, $matches); $result = array(); foreach ($matches[1] as $commits) { $commits = preg_split('/[,\s]+/', $commits); $commits = array_filter($commits); foreach ($commits as $commit) { $result[$commit] = $commit; } } return $result; } public function didWriteRevision(DifferentialRevisionEditor $editor) { $message = $this->renderValueForCommitMessage(false); $tasks = $this->findMentionedTasks($message); if ($tasks) { $tasks = id(new ManiphestTask()) ->loadAllWhere('id IN (%Ld)', array_keys($tasks)); $this->saveFieldEdges( $editor->getRevision(), PhabricatorEdgeConfig::TYPE_DREV_HAS_RELATED_TASK, mpull($tasks, 'getPHID')); } $dependents = $this->findDependentRevisions($message); if ($dependents) { $dependents = id(new DifferentialRevision()) ->loadAllWhere('id IN (%Ld)', $dependents); $this->saveFieldEdges( $editor->getRevision(), PhabricatorEdgeConfig::TYPE_DREV_DEPENDS_ON_DREV, mpull($dependents, 'getPHID')); } } private function saveFieldEdges( DifferentialRevision $revision, $edge_type, array $add_phids) { $revision_phid = $revision->getPHID(); $old_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $revision_phid, $edge_type); $add_phids = array_diff($add_phids, $old_phids); if (!$add_phids) { return; } $edge_editor = id(new PhabricatorEdgeEditor())->setActor($this->getUser()); foreach ($add_phids as $phid) { $edge_editor->addEdge($revision_phid, $edge_type, $phid); } // NOTE: Deletes only through the fields. $edge_editor->save(); } public function didParseCommit( PhabricatorRepository $repository, PhabricatorRepositoryCommit $commit, PhabricatorRepositoryCommitData $data) { $message = $this->renderValueForCommitMessage($is_edit = false); $user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $data->getCommitDetail('authorPHID')); if (!$user) { // TODO: Maybe after grey users, we should find a way to proceed even // if we don't know who the author is. return; } $commit_names = self::findRevertedCommits($message); if ($commit_names) { $reverts = id(new DiffusionCommitQuery()) ->setViewer($user) ->withIdentifiers($commit_names) ->withDefaultRepository($repository) ->execute(); foreach ($reverts as $revert) { // TODO: Do interesting things here. } } $tasks_statuses = $this->findMentionedTasks($message); if (!$tasks_statuses) { return; } $tasks = id(new ManiphestTaskQuery()) + ->setViewer($user) ->withTaskIDs(array_keys($tasks_statuses)) ->execute(); foreach ($tasks as $task_id => $task) { id(new PhabricatorEdgeEditor()) ->setActor($user) ->addEdge( $task->getPHID(), PhabricatorEdgeConfig::TYPE_TASK_HAS_COMMIT, $commit->getPHID()) ->save(); $status = $tasks_statuses[$task_id]; if (!$status) { // Text like "Ref T123", don't change the task status. continue; } if ($task->getStatus() != ManiphestTaskStatus::STATUS_OPEN) { // Task is already closed. continue; } $commit_name = $repository->formatCommitName( $commit->getCommitIdentifier()); $call = new ConduitCall( 'maniphest.update', array( 'id' => $task->getID(), 'status' => $status, 'comments' => "Closed by commit {$commit_name}.", )); $call->setUser($user); $call->execute(); } } } diff --git a/src/applications/directory/controller/PhabricatorDirectoryMainController.php b/src/applications/directory/controller/PhabricatorDirectoryMainController.php index 9b9c469820..b2505ccbbb 100644 --- a/src/applications/directory/controller/PhabricatorDirectoryMainController.php +++ b/src/applications/directory/controller/PhabricatorDirectoryMainController.php @@ -1,476 +1,477 @@ filter = idx($data, 'filter'); } public function processRequest() { $user = $this->getRequest()->getUser(); if ($this->filter == 'jump') { return $this->buildJumpResponse(); } $nav = $this->buildNav(); $project_query = new PhabricatorProjectQuery(); $project_query->setViewer($user); $project_query->withMemberPHIDs(array($user->getPHID())); $projects = $project_query->execute(); return $this->buildMainResponse($nav, $projects); } private function buildMainResponse($nav, array $projects) { assert_instances_of($projects, 'PhabricatorProject'); $maniphest = 'PhabricatorApplicationManiphest'; if (PhabricatorApplication::isClassInstalled($maniphest)) { $unbreak_panel = $this->buildUnbreakNowPanel(); $triage_panel = $this->buildNeedsTriagePanel($projects); $tasks_panel = $this->buildTasksPanel(); } else { $unbreak_panel = null; $triage_panel = null; $tasks_panel = null; } if (PhabricatorEnv::getEnvConfig('welcome.html') !== null) { $welcome_panel = $this->buildWelcomePanel(); } else { $welcome_panel = null; } $jump_panel = $this->buildJumpPanel(); $revision_panel = $this->buildRevisionPanel(); $audit_panel = $this->buildAuditPanel(); $commit_panel = $this->buildCommitPanel(); $content = array( $jump_panel, $welcome_panel, $unbreak_panel, $triage_panel, $revision_panel, $tasks_panel, $audit_panel, $commit_panel, $this->minipanels, ); $nav->appendChild($content); $nav->appendChild(new PhabricatorGlobalUploadTargetView()); return $this->buildApplicationPage( $nav, array( 'title' => 'Phabricator', 'device' => true, )); } private function buildJumpResponse() { $request = $this->getRequest(); $jump = $request->getStr('jump'); $response = PhabricatorJumpNavHandler::jumpPostResponse($jump); if ($response) { return $response; } else if ($request->isFormPost()) { $query = new PhabricatorSearchQuery(); $query->setQuery($jump); $query->save(); return id(new AphrontRedirectResponse()) ->setURI('/search/'.$query->getQueryKey().'/'); } else { return id(new AphrontRedirectResponse())->setURI('/'); } } private function buildUnbreakNowPanel() { $user = $this->getRequest()->getUser(); - $user_phid = $user->getPHID(); - $task_query = new ManiphestTaskQuery(); - $task_query->withStatus(ManiphestTaskQuery::STATUS_OPEN); - $task_query->withPriority(ManiphestTaskPriority::PRIORITY_UNBREAK_NOW); - $task_query->setLimit(10); + $task_query = id(new ManiphestTaskQuery()) + ->setViewer($user) + ->withStatus(ManiphestTaskQuery::STATUS_OPEN) + ->withPriority(ManiphestTaskPriority::PRIORITY_UNBREAK_NOW) + ->setLimit(10); $tasks = $task_query->execute(); if (!$tasks) { return $this->renderMiniPanel( 'No "Unbreak Now!" Tasks', 'Nothing appears to be critically broken right now.'); } $panel = new AphrontPanelView(); $panel->setHeader('Unbreak Now!'); $panel->setCaption('Open tasks with "Unbreak Now!" priority.'); $panel->addButton( phutil_tag( 'a', array( 'href' => '/maniphest/view/all/', 'class' => 'grey button', ), "View All Unbreak Now \xC2\xBB")); $panel->appendChild($this->buildTaskListView($tasks)); $panel->setNoBackground(); return $panel; } private function buildNeedsTriagePanel(array $projects) { assert_instances_of($projects, 'PhabricatorProject'); $user = $this->getRequest()->getUser(); - $user_phid = $user->getPHID(); if ($projects) { - $task_query = new ManiphestTaskQuery(); - $task_query->withStatus(ManiphestTaskQuery::STATUS_OPEN); - $task_query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); - $task_query->withAnyProjects(mpull($projects, 'getPHID')); - $task_query->setLimit(10); + $task_query = id(new ManiphestTaskQuery()) + ->setViewer($user) + ->withStatus(ManiphestTaskQuery::STATUS_OPEN) + ->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE) + ->withAnyProjects(mpull($projects, 'getPHID')) + ->setLimit(10); $tasks = $task_query->execute(); } else { $tasks = array(); } if (!$tasks) { return $this->renderMiniPanel( 'No "Needs Triage" Tasks', hsprintf( 'No tasks in projects you are a member of '. 'need triage.')); } $panel = new AphrontPanelView(); $panel->setHeader('Needs Triage'); $panel->setCaption(hsprintf( 'Open tasks with "Needs Triage" priority in '. 'projects you are a member of.')); $panel->addButton( phutil_tag( 'a', array( // TODO: This should filter to just your projects' need-triage // tasks? 'href' => '/maniphest/view/projecttriage/', 'class' => 'grey button', ), "View All Triage \xC2\xBB")); $panel->appendChild($this->buildTaskListView($tasks)); $panel->setNoBackground(); return $panel; } private function buildRevisionPanel() { $user = $this->getRequest()->getUser(); $user_phid = $user->getPHID(); $revision_query = id(new DifferentialRevisionQuery()) ->setViewer($user) ->withStatus(DifferentialRevisionQuery::STATUS_OPEN) ->withResponsibleUsers(array($user_phid)) ->needRelationships(true); $revisions = $revision_query->execute(); list($blocking, $active, ) = DifferentialRevisionQuery::splitResponsible( $revisions, array($user_phid)); if (!$blocking && !$active) { return $this->renderMiniPanel( 'No Waiting Revisions', 'No revisions are waiting on you.'); } $panel = new AphrontPanelView(); $panel->setHeader('Revisions Waiting on You'); $panel->setCaption('Revisions waiting for you for review or commit.'); $panel->addButton( phutil_tag( 'a', array( 'href' => '/differential/', 'class' => 'button grey', ), "View Active Revisions \xC2\xBB")); $revision_view = id(new DifferentialRevisionListView()) ->setHighlightAge(true) ->setRevisions(array_merge($blocking, $active)) ->setFields(DifferentialRevisionListView::getDefaultFields($user)) ->setUser($user) ->loadAssets(); $phids = array_merge( array($user_phid), $revision_view->getRequiredHandlePHIDs()); $handles = $this->loadViewerHandles($phids); $revision_view->setHandles($handles); $list_view = $revision_view->render(); $list_view->setFlush(true); $panel->appendChild($list_view); $panel->setNoBackground(); return $panel; } private function buildWelcomePanel() { $panel = new AphrontPanelView(); $panel->appendChild( phutil_safe_html( PhabricatorEnv::getEnvConfig('welcome.html'))); $panel->setNoBackground(); return $panel; } private function buildTasksPanel() { $user = $this->getRequest()->getUser(); $user_phid = $user->getPHID(); - $task_query = new ManiphestTaskQuery(); - $task_query->withStatus(ManiphestTaskQuery::STATUS_OPEN); - $task_query->setGroupBy(ManiphestTaskQuery::GROUP_PRIORITY); - $task_query->withOwners(array($user_phid)); - $task_query->setLimit(10); + $task_query = id(new ManiphestTaskQuery()) + ->setViewer($user) + ->withStatus(ManiphestTaskQuery::STATUS_OPEN) + ->setGroupBy(ManiphestTaskQuery::GROUP_PRIORITY) + ->withOwners(array($user_phid)) + ->setLimit(10); $tasks = $task_query->execute(); if (!$tasks) { return $this->renderMiniPanel( 'No Assigned Tasks', 'You have no assigned tasks.'); } $panel = new AphrontPanelView(); $panel->setHeader('Assigned Tasks'); $panel->addButton( phutil_tag( 'a', array( 'href' => '/maniphest/', 'class' => 'button grey', ), "View Active Tasks \xC2\xBB")); $panel->appendChild($this->buildTaskListView($tasks)); $panel->setNoBackground(); return $panel; } private function buildTaskListView(array $tasks) { assert_instances_of($tasks, 'ManiphestTask'); $user = $this->getRequest()->getUser(); $phids = array_merge( array_filter(mpull($tasks, 'getOwnerPHID')), array_mergev(mpull($tasks, 'getProjectPHIDs'))); $handles = $this->loadViewerHandles($phids); $view = new ManiphestTaskListView(); $view->setTasks($tasks); $view->setUser($user); $view->setHandles($handles); return $view; } private function buildJumpPanel($query=null) { $request = $this->getRequest(); $user = $request->getUser(); $uniq_id = celerity_generate_unique_node_id(); Javelin::initBehavior( 'phabricator-autofocus', array( 'id' => $uniq_id, )); require_celerity_resource('phabricator-jump-nav'); $doc_href = PhabricatorEnv::getDocLink('article/Jump_Nav_User_Guide.html'); $doc_link = phutil_tag( 'a', array( 'href' => $doc_href, ), 'Jump Nav User Guide'); $jump_input = phutil_tag( 'input', array( 'type' => 'text', 'class' => 'phabricator-jump-nav', 'name' => 'jump', 'id' => $uniq_id, 'value' => $query, )); $jump_caption = phutil_tag( 'p', array( 'class' => 'phabricator-jump-nav-caption', ), hsprintf( 'Enter the name of an object like D123 to quickly jump to '. 'it. See %s or type help.', $doc_link)); $form = phabricator_form( $user, array( 'action' => '/jump/', 'method' => 'POST', 'class' => 'phabricator-jump-nav-form', ), array( $jump_input, $jump_caption, )); $panel = new AphrontPanelView(); $panel->setNoBackground(); // $panel->appendChild(); $list_filter = new AphrontListFilterView(); $list_filter->appendChild($form); $container = phutil_tag('div', array('class' => 'phabricator-jump-nav-container'), $list_filter); return $container; } private function renderMiniPanel($title, $body) { $panel = new AphrontMiniPanelView(); $panel->appendChild( phutil_tag( 'p', array( ), array( phutil_tag('strong', array(), $title.': '), $body ))); $this->minipanels[] = $panel; } public function buildAuditPanel() { $request = $this->getRequest(); $user = $request->getUser(); $phids = PhabricatorAuditCommentEditor::loadAuditPHIDsForUser($user); $query = new PhabricatorAuditQuery(); $query->withAuditorPHIDs($phids); $query->withStatus(PhabricatorAuditQuery::STATUS_OPEN); $query->withAwaitingUser($user); $query->needCommitData(true); $query->setLimit(10); $audits = $query->execute(); $commits = $query->getCommits(); if (!$audits) { return $this->renderMinipanel( 'No Audits', 'No commits are waiting for you to audit them.'); } $view = new PhabricatorAuditListView(); $view->setAudits($audits); $view->setCommits($commits); $view->setUser($user); $phids = $view->getRequiredHandlePHIDs(); $handles = $this->loadViewerHandles($phids); $view->setHandles($handles); $panel = new AphrontPanelView(); $panel->setHeader('Audits'); $panel->setCaption('Commits awaiting your audit.'); $panel->appendChild($view); $panel->addButton( phutil_tag( 'a', array( 'href' => '/audit/', 'class' => 'button grey', ), "View Active Audits \xC2\xBB")); $panel->setNoBackground(); return $panel; } public function buildCommitPanel() { $request = $this->getRequest(); $user = $request->getUser(); $phids = array($user->getPHID()); $query = new PhabricatorAuditCommitQuery(); $query->withAuthorPHIDs($phids); $query->withStatus(PhabricatorAuditCommitQuery::STATUS_CONCERN); $query->needCommitData(true); $query->setLimit(10); $commits = $query->execute(); if (!$commits) { return $this->renderMinipanel( 'No Problem Commits', 'No one has raised concerns with your commits.'); } $view = new PhabricatorAuditCommitListView(); $view->setCommits($commits); $view->setUser($user); $phids = $view->getRequiredHandlePHIDs(); $handles = $this->loadViewerHandles($phids); $view->setHandles($handles); $panel = new AphrontPanelView(); $panel->setHeader('Problem Commits'); $panel->setCaption('Commits which auditors have raised concerns about.'); $panel->appendChild($view); $panel->addButton( phutil_tag( 'a', array( 'href' => '/audit/', 'class' => 'button grey', ), "View Problem Commits \xC2\xBB")); $panel->setNoBackground(); return $panel; } } diff --git a/src/applications/maniphest/application/PhabricatorApplicationManiphest.php b/src/applications/maniphest/application/PhabricatorApplicationManiphest.php index a7939808d7..9b7afa86a8 100644 --- a/src/applications/maniphest/application/PhabricatorApplicationManiphest.php +++ b/src/applications/maniphest/application/PhabricatorApplicationManiphest.php @@ -1,117 +1,119 @@ getBaseURI().'task/create/'; } public function getEventListeners() { return array( new ManiphestPeopleMenuEventListener(), new ManiphestHovercardEventListener(), ); } public function getRemarkupRules() { return array( new ManiphestRemarkupRule(), ); } public function getRoutes() { return array( '/T(?P[1-9]\d*)' => 'ManiphestTaskDetailController', '/maniphest/' => array( '' => 'ManiphestTaskListController', 'view/(?P\w+)/' => 'ManiphestTaskListController', 'report/(?:(?P\w+)/)?' => 'ManiphestReportController', 'batch/' => 'ManiphestBatchEditController', 'task/' => array( 'create/' => 'ManiphestTaskEditController', 'edit/(?P[1-9]\d*)/' => 'ManiphestTaskEditController', 'descriptionchange/(?:(?P[1-9]\d*)/)?' => 'ManiphestTaskDescriptionChangeController', 'descriptionpreview/' => 'PhabricatorMarkupPreviewController', ), 'transaction/' => array( 'save/' => 'ManiphestTransactionSaveController', 'preview/(?P[1-9]\d*)/' => 'ManiphestTransactionPreviewController', ), 'export/(?P[^/]+)/' => 'ManiphestExportController', 'subpriority/' => 'ManiphestSubpriorityController', 'custom/' => array( '' => 'ManiphestSavedQueryListController', 'edit/(?:(?P[1-9]\d*)/)?' => 'ManiphestSavedQueryEditController', 'delete/(?P[1-9]\d*)/' => 'ManiphestSavedQueryDeleteController', ), 'subscribe/(?Padd|rem)/(?P[1-9]\d*)/' => 'ManiphestSubscribeController', ), ); } public function loadStatus(PhabricatorUser $user) { $status = array(); $query = id(new ManiphestTaskQuery()) + ->setViewer($user) ->withStatus(ManiphestTaskQuery::STATUS_OPEN) ->withPriority(ManiphestTaskPriority::PRIORITY_UNBREAK_NOW) ->setLimit(1) ->setCalculateRows(true); $query->execute(); $count = $query->getRowCount(); $type = PhabricatorApplicationStatusView::TYPE_NEEDS_ATTENTION; $status[] = id(new PhabricatorApplicationStatusView()) ->setType($type) ->setText(pht('%d Unbreak Now Task(s)!', $count)) ->setCount($count); $query = id(new ManiphestTaskQuery()) + ->setViewer($user) ->withStatus(ManiphestTaskQuery::STATUS_OPEN) ->withOwners(array($user->getPHID())) ->setLimit(1) ->setCalculateRows(true); $query->execute(); $count = $query->getRowCount(); $type = PhabricatorApplicationStatusView::TYPE_WARNING; $status[] = id(new PhabricatorApplicationStatusView()) ->setType($type) ->setText(pht('%d Assigned Task(s)', $count)) ->setCount($count); return $status; } } diff --git a/src/applications/maniphest/conduit/ConduitAPI_maniphest_query_Method.php b/src/applications/maniphest/conduit/ConduitAPI_maniphest_query_Method.php index 346944f2e3..fc3397f79e 100644 --- a/src/applications/maniphest/conduit/ConduitAPI_maniphest_query_Method.php +++ b/src/applications/maniphest/conduit/ConduitAPI_maniphest_query_Method.php @@ -1,127 +1,129 @@ 'optional list', 'phids' => 'optional list', 'ownerPHIDs' => 'optional list', 'authorPHIDs' => 'optional list', 'projectPHIDs' => 'optional list', 'ccPHIDs' => 'optional list', 'fullText' => 'optional string', 'status' => 'optional enum<'.$statuses.'>', 'order' => 'optional enum<'.$orders.'>', 'limit' => 'optional int', 'offset' => 'optional int', ); } public function defineReturnType() { return 'list'; } public function defineErrorTypes() { return array( ); } protected function execute(ConduitAPIRequest $request) { $query = new ManiphestTaskQuery(); + $query->setViewer($request->getUser()); + $task_ids = $request->getValue('ids'); if ($task_ids) { $query->withTaskIDs($task_ids); } $task_phids = $request->getValue('phids'); if ($task_phids) { $query->withTaskPHIDs($task_phids); } $owners = $request->getValue('ownerPHIDs'); if ($owners) { $query->withOwners($owners); } $authors = $request->getValue('authorPHIDs'); if ($authors) { $query->withAuthors($authors); } $projects = $request->getValue('projectPHIDs'); if ($projects) { $query->withAllProjects($projects); } $ccs = $request->getValue('ccPHIDs'); if ($ccs) { $query->withSubscribers($ccs); } $full_text = $request->getValue('fullText'); if ($full_text) { $query->withFullTextSearch($full_text); } $status = $request->getValue('status'); if ($status) { $query->withStatus($status); } $order = $request->getValue('order'); if ($order) { $query->setOrderBy($order); } $limit = $request->getValue('limit'); if ($limit) { $query->setLimit($limit); } $offset = $request->getValue('offset'); if ($offset) { $query->setOffset($offset); } $results = $query->execute(); return $this->buildTaskInfoDictionaries($results); } } diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php index ea94b69008..8f0cc46e3f 100644 --- a/src/applications/maniphest/controller/ManiphestReportController.php +++ b/src/applications/maniphest/controller/ManiphestReportController.php @@ -1,767 +1,768 @@ view = idx($data, 'view'); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); if ($request->isFormPost()) { $uri = $request->getRequestURI(); $project = head($request->getArr('set_project')); $project = nonempty($project, null); $uri = $uri->alter('project', $project); $window = $request->getStr('set_window'); $uri = $uri->alter('window', $window); return id(new AphrontRedirectResponse())->setURI($uri); } $base_nav = $this->buildBaseSideNav(); $base_nav->selectFilter('report', 'report'); $nav = new AphrontSideNavFilterView(); $nav->setBaseURI(new PhutilURI('/maniphest/report/')); $nav->addLabel(pht('Open Tasks')); $nav->addFilter('user', pht('By User')); $nav->addFilter('project', pht('By Project')); $nav->addLabel(pht('Burnup')); $nav->addFilter('burn', pht('Burnup Rate')); $this->view = $nav->selectFilter($this->view, 'user'); require_celerity_resource('maniphest-report-css'); switch ($this->view) { case 'burn': $core = $this->renderBurn(); break; case 'user': case 'project': $core = $this->renderOpenTasks(); break; default: return new Aphront404Response(); } $nav->appendChild($core); $nav->setCrumbs( $this->buildApplicationCrumbs() ->addCrumb( id(new PhabricatorCrumbView()) ->setName(pht('Reports')))); return $this->buildStandardPageResponse( $nav, array( 'title' => pht('Maniphest Reports'), )); } public function renderBurn() { $request = $this->getRequest(); $user = $request->getUser(); $handle = null; $project_phid = $request->getStr('project'); if ($project_phid) { $phids = array($project_phid); $handles = $this->loadViewerHandles($phids); $handle = $handles[$project_phid]; } $table = new ManiphestTransaction(); $conn = $table->establishConnection('r'); $joins = ''; if ($project_phid) { $joins = qsprintf( $conn, 'JOIN %T t ON x.taskID = t.id JOIN %T p ON p.taskPHID = t.phid AND p.projectPHID = %s', id(new ManiphestTask())->getTableName(), id(new ManiphestTaskProject())->getTableName(), $project_phid); } $data = queryfx_all( $conn, 'SELECT x.oldValue, x.newValue, x.dateCreated FROM %T x %Q WHERE transactionType = %s ORDER BY x.dateCreated ASC', $table->getTableName(), $joins, ManiphestTransactionType::TYPE_STATUS); $stats = array(); $day_buckets = array(); $open_tasks = array(); foreach ($data as $key => $row) { // NOTE: Hack to avoid json_decode(). $oldv = trim($row['oldValue'], '"'); $newv = trim($row['newValue'], '"'); $old_is_open = ($oldv === (string)ManiphestTaskStatus::STATUS_OPEN); $new_is_open = ($newv === (string)ManiphestTaskStatus::STATUS_OPEN); $is_open = ($new_is_open && !$old_is_open); $is_close = ($old_is_open && !$new_is_open); $data[$key]['_is_open'] = $is_open; $data[$key]['_is_close'] = $is_close; if (!$is_open && !$is_close) { // This is either some kind of bogus event, or a resolution change // (e.g., resolved -> invalid). Just skip it. continue; } $day_bucket = phabricator_format_local_time( $row['dateCreated'], $user, 'Yz'); $day_buckets[$day_bucket] = $row['dateCreated']; if (empty($stats[$day_bucket])) { $stats[$day_bucket] = array( 'open' => 0, 'close' => 0, ); } $stats[$day_bucket][$is_close ? 'close' : 'open']++; } $template = array( 'open' => 0, 'close' => 0, ); $rows = array(); $rowc = array(); $last_month = null; $last_month_epoch = null; $last_week = null; $last_week_epoch = null; $week = null; $month = null; $last = last_key($stats) - 1; $period = $template; foreach ($stats as $bucket => $info) { $epoch = $day_buckets[$bucket]; $week_bucket = phabricator_format_local_time( $epoch, $user, 'YW'); if ($week_bucket != $last_week) { if ($week) { $rows[] = $this->formatBurnRow( 'Week of '.phabricator_date($last_week_epoch, $user), $week); $rowc[] = 'week'; } $week = $template; $last_week = $week_bucket; $last_week_epoch = $epoch; } $month_bucket = phabricator_format_local_time( $epoch, $user, 'Ym'); if ($month_bucket != $last_month) { if ($month) { $rows[] = $this->formatBurnRow( phabricator_format_local_time($last_month_epoch, $user, 'F, Y'), $month); $rowc[] = 'month'; } $month = $template; $last_month = $month_bucket; $last_month_epoch = $epoch; } $rows[] = $this->formatBurnRow(phabricator_date($epoch, $user), $info); $rowc[] = null; $week['open'] += $info['open']; $week['close'] += $info['close']; $month['open'] += $info['open']; $month['close'] += $info['close']; $period['open'] += $info['open']; $period['close'] += $info['close']; } if ($week) { $rows[] = $this->formatBurnRow( pht('Week To Date'), $week); $rowc[] = 'week'; } if ($month) { $rows[] = $this->formatBurnRow( pht('Month To Date'), $month); $rowc[] = 'month'; } $rows[] = $this->formatBurnRow( pht('All Time'), $period); $rowc[] = 'aggregate'; $rows = array_reverse($rows); $rowc = array_reverse($rowc); $table = new AphrontTableView($rows); $table->setRowClasses($rowc); $table->setHeaders( array( pht('Period'), pht('Opened'), pht('Closed'), pht('Change'), )); $table->setColumnClasses( array( 'right wide', 'n', 'n', 'n', )); if ($handle) { $inst = pht( "NOTE: This table reflects tasks currently in ". "the project. If a task was opened in the past but added to ". "the project recently, it is counted on the day it was ". "opened, not the day it was categorized. If a task was part ". "of this project in the past but no longer is, it is not ". "counted at all."); $header = pht("Task Burn Rate for Project %s", $handle->renderLink()); $caption = hsprintf("

%s

", $inst); } else { $header = pht("Task Burn Rate for All Tasks"); $caption = null; } $panel = new AphrontPanelView(); $panel->setHeader($header); $panel->setCaption($caption); $panel->appendChild($table); $tokens = array(); if ($handle) { $tokens = array( $handle->getPHID() => $handle->getFullName(), ); } $filter = $this->renderReportFilters($tokens, $has_window = false); $id = celerity_generate_unique_node_id(); $chart = phutil_tag( 'div', array( 'id' => $id, 'style' => 'border: 1px solid #6f6f6f; '. 'margin: 1em 2em; '. 'height: 400px; ', ), ''); list($burn_x, $burn_y) = $this->buildSeries($data); require_celerity_resource('raphael-core'); require_celerity_resource('raphael-g'); require_celerity_resource('raphael-g-line'); Javelin::initBehavior('line-chart', array( 'hardpoint' => $id, 'x' => array( $burn_x, ), 'y' => array( $burn_y, ), 'xformat' => 'epoch', )); return array($filter, $chart, $panel); } private function renderReportFilters(array $tokens, $has_window) { $request = $this->getRequest(); $user = $request->getUser(); $form = id(new AphrontFormView()) ->setUser($user) ->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchproject/') ->setLabel(pht('Project')) ->setLimit(1) ->setName('set_project') ->setValue($tokens)); if ($has_window) { list($window_str, $ignored, $window_error) = $this->getWindow(); $form ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Recently Means')) ->setName('set_window') ->setCaption( pht('Configure the cutoff for the "Recently Closed" column.')) ->setValue($window_str) ->setError($window_error)); } $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Filter By Project'))); $filter = new AphrontListFilterView(); $filter->appendChild($form); return $filter; } private function buildSeries(array $data) { $out = array(); $counter = 0; foreach ($data as $row) { $t = (int)$row['dateCreated']; if ($row['_is_close']) { --$counter; $out[$t] = $counter; } else if ($row['_is_open']) { ++$counter; $out[$t] = $counter; } } return array(array_keys($out), array_values($out)); } private function formatBurnRow($label, $info) { $delta = $info['open'] - $info['close']; $fmt = number_format($delta); if ($delta > 0) { $fmt = '+'.$fmt; $fmt = hsprintf('%s', $fmt); } else { $fmt = hsprintf('%s', $fmt); } return array( $label, number_format($info['open']), number_format($info['close']), $fmt); } public function renderOpenTasks() { $request = $this->getRequest(); $user = $request->getUser(); $query = id(new ManiphestTaskQuery()) + ->setViewer($user) ->withStatus(ManiphestTaskQuery::STATUS_OPEN); $project_phid = $request->getStr('project'); $project_handle = null; if ($project_phid) { $phids = array($project_phid); $handles = $this->loadViewerHandles($phids); $project_handle = $handles[$project_phid]; $query->withAnyProjects($phids); } $tasks = $query->execute(); $recently_closed = $this->loadRecentlyClosedTasks(); $date = phabricator_date(time(), $user); switch ($this->view) { case 'user': $result = mgroup($tasks, 'getOwnerPHID'); $leftover = idx($result, '', array()); unset($result['']); $result_closed = mgroup($recently_closed, 'getOwnerPHID'); $leftover_closed = idx($result_closed, '', array()); unset($result_closed['']); $base_link = '/maniphest/?users='; $leftover_name = phutil_tag( 'a', array( 'href' => $base_link.ManiphestTaskOwner::OWNER_UP_FOR_GRABS, ), phutil_tag('em', array(), pht('(Up For Grabs)'))); $col_header = pht('User'); $header = pht('Open Tasks by User and Priority (%s)', $date); break; case 'project': $result = array(); $leftover = array(); foreach ($tasks as $task) { $phids = $task->getProjectPHIDs(); if ($phids) { foreach ($phids as $project_phid) { $result[$project_phid][] = $task; } } else { $leftover[] = $task; } } $result_closed = array(); $leftover_closed = array(); foreach ($recently_closed as $task) { $phids = $task->getProjectPHIDs(); if ($phids) { foreach ($phids as $project_phid) { $result_closed[$project_phid][] = $task; } } else { $leftover_closed[] = $task; } } $base_link = '/maniphest/view/all/?projects='; $leftover_name = phutil_tag( 'a', array( 'href' => $base_link.ManiphestTaskOwner::PROJECT_NO_PROJECT, ), phutil_tag('em', array(), pht('(No Project)'))); $col_header = pht('Project'); $header = pht('Open Tasks by Project and Priority (%s)', $date); break; } $phids = array_keys($result); $handles = $this->loadViewerHandles($phids); $handles = msort($handles, 'getName'); $order = $request->getStr('order', 'name'); list($order, $reverse) = AphrontTableView::parseSort($order); require_celerity_resource('aphront-tooltip-css'); Javelin::initBehavior('phabricator-tooltips', array()); $rows = array(); $pri_total = array(); foreach (array_merge($handles, array(null)) as $handle) { if ($handle) { if (($project_handle) && ($project_handle->getPHID() == $handle->getPHID())) { // If filtering by, e.g., "bugs", don't show a "bugs" group. continue; } $tasks = idx($result, $handle->getPHID(), array()); $name = phutil_tag( 'a', array( 'href' => $base_link.$handle->getPHID(), ), $handle->getName()); $closed = idx($result_closed, $handle->getPHID(), array()); } else { $tasks = $leftover; $name = $leftover_name; $closed = $leftover_closed; } $taskv = $tasks; $tasks = mgroup($tasks, 'getPriority'); $row = array(); $row[] = $name; $total = 0; foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) { $n = count(idx($tasks, $pri, array())); if ($n == 0) { $row[] = '-'; } else { $row[] = number_format($n); } $total += $n; } $row[] = number_format($total); list($link, $oldest_all) = $this->renderOldest($taskv); $row[] = $link; $normal_or_better = array(); foreach ($taskv as $id => $task) { if ($task->getPriority() < ManiphestTaskPriority::PRIORITY_NORMAL) { continue; } $normal_or_better[$id] = $task; } list($link, $oldest_pri) = $this->renderOldest($normal_or_better); $row[] = $link; if ($closed) { $task_ids = implode(',', mpull($closed, 'getID')); $row[] = phutil_tag( 'a', array( 'href' => '/maniphest/view/custom/?s=oc&tasks='.$task_ids, 'target' => '_blank', ), number_format(count($closed))); } else { $row[] = '-'; } switch ($order) { case 'total': $row['sort'] = $total; break; case 'oldest-all': $row['sort'] = $oldest_all; break; case 'oldest-pri': $row['sort'] = $oldest_pri; break; case 'closed': $row['sort'] = count($closed); break; case 'name': default: $row['sort'] = $handle ? $handle->getName() : '~'; break; } $rows[] = $row; } $rows = isort($rows, 'sort'); foreach ($rows as $k => $row) { unset($rows[$k]['sort']); } if ($reverse) { $rows = array_reverse($rows); } $cname = array($col_header); $cclass = array('pri right wide'); $pri_map = ManiphestTaskPriority::getTaskBriefPriorityMap(); foreach ($pri_map as $pri => $label) { $cname[] = $label; $cclass[] = 'n'; } $cname[] = 'Total'; $cclass[] = 'n'; $cname[] = javelin_tag( 'span', array( 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => pht('Oldest open task.'), 'size' => 200, ), ), pht('Oldest (All)')); $cclass[] = 'n'; $cname[] = javelin_tag( 'span', array( 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => pht('Oldest open task, excluding those with Low or '. 'Wishlist priority.'), 'size' => 200, ), ), pht('Oldest (Pri)')); $cclass[] = 'n'; list($ignored, $window_epoch) = $this->getWindow(); $edate = phabricator_datetime($window_epoch, $user); $cname[] = javelin_tag( 'span', array( 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => pht('Closed after %s', $edate), 'size' => 260 ), ), pht('Recently Closed')); $cclass[] = 'n'; $table = new AphrontTableView($rows); $table->setHeaders($cname); $table->setColumnClasses($cclass); $table->makeSortable( $request->getRequestURI(), 'order', $order, $reverse, array( 'name', null, null, null, null, null, null, 'total', 'oldest-all', 'oldest-pri', 'closed', )); $panel = new AphrontPanelView(); $panel->setHeader($header); $panel->appendChild($table); $tokens = array(); if ($project_handle) { $tokens = array( $project_handle->getPHID() => $project_handle->getFullName(), ); } $filter = $this->renderReportFilters($tokens, $has_window = true); return array($filter, $panel); } /** * Load all the tasks that have been recently closed. */ private function loadRecentlyClosedTasks() { list($ignored, $window_epoch) = $this->getWindow(); $table = new ManiphestTask(); $xtable = new ManiphestTransaction(); $conn_r = $table->establishConnection('r'); $tasks = queryfx_all( $conn_r, 'SELECT t.* FROM %T t JOIN %T x ON x.taskID = t.id WHERE t.status != 0 AND x.oldValue IN (null, %s, %s) AND x.newValue NOT IN (%s, %s) AND t.dateModified >= %d AND x.dateCreated >= %d', $table->getTableName(), $xtable->getTableName(), // TODO: Gross. This table is not meant to be queried like this. Build // real stats tables. json_encode((int)ManiphestTaskStatus::STATUS_OPEN), json_encode((string)ManiphestTaskStatus::STATUS_OPEN), json_encode((int)ManiphestTaskStatus::STATUS_OPEN), json_encode((string)ManiphestTaskStatus::STATUS_OPEN), $window_epoch, $window_epoch); return id(new ManiphestTask())->loadAllFromArray($tasks); } /** * Parse the "Recently Means" filter into: * * - A string representation, like "12 AM 7 days ago" (default); * - a locale-aware epoch representation; and * - a possible error. */ private function getWindow() { $request = $this->getRequest(); $user = $request->getUser(); $window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago'); $error = null; $window_epoch = null; // Do locale-aware parsing so that the user's timezone is assumed for // time windows like "3 PM", rather than assuming the server timezone. $window_epoch = PhabricatorTime::parseLocalTime($window_str, $user); if (!$window_epoch) { $error = 'Invalid'; $window_epoch = time() - (60 * 60 * 24 * 7); } // If the time ends up in the future, convert it to the corresponding time // and equal distance in the past. This is so users can type "6 days" (which // means "6 days from now") and get the behavior of "6 days ago", rather // than no results (because the window epoch is in the future). This might // be a little confusing because it casues "tomorrow" to mean "yesterday" // and "2022" (or whatever) to mean "ten years ago", but these inputs are // nonsense anyway. if ($window_epoch > time()) { $window_epoch = time() - ($window_epoch - time()); } return array($window_str, $window_epoch, $error); } private function renderOldest(array $tasks) { assert_instances_of($tasks, 'ManiphestTask'); $oldest = null; foreach ($tasks as $id => $task) { if (($oldest === null) || ($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) { $oldest = $id; } } if ($oldest === null) { return array('-', 0); } $oldest = $tasks[$oldest]; $raw_age = (time() - $oldest->getDateCreated()); $age = number_format($raw_age / (24 * 60 * 60)).' d'; $link = javelin_tag( 'a', array( 'href' => '/T'.$oldest->getID(), 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(), ), 'target' => '_blank', ), $age); return array($link, $raw_age); } } diff --git a/src/applications/maniphest/controller/ManiphestTaskListController.php b/src/applications/maniphest/controller/ManiphestTaskListController.php index f2f6893f0f..3bb615c7d4 100644 --- a/src/applications/maniphest/controller/ManiphestTaskListController.php +++ b/src/applications/maniphest/controller/ManiphestTaskListController.php @@ -1,976 +1,977 @@ view = idx($data, 'view'); } private function getArrToStrList($key) { $arr = $this->getRequest()->getArr($key); $arr = implode(',', $arr); return nonempty($arr, null); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); if ($request->isFormPost()) { // Redirect to GET so URIs can be copy/pasted. $task_ids = $request->getStr('set_tasks'); $task_ids = nonempty($task_ids, null); $search_text = $request->getStr('set_search'); $min_priority = $request->getInt('set_lpriority'); $max_priority = $request->getInt('set_hpriority'); $uri = $request->getRequestURI() ->alter('users', $this->getArrToStrList('set_users')) ->alter('projects', $this->getArrToStrList('set_projects')) ->alter('aprojects', $this->getArrToStrList('set_aprojects')) ->alter('useraprojects', $this->getArrToStrList('set_useraprojects')) ->alter('xprojects', $this->getArrToStrList('set_xprojects')) ->alter('owners', $this->getArrToStrList('set_owners')) ->alter('authors', $this->getArrToStrList('set_authors')) ->alter('lpriority', $min_priority) ->alter('hpriority', $max_priority) ->alter('tasks', $task_ids) ->alter('search', $search_text); return id(new AphrontRedirectResponse())->setURI($uri); } $nav = $this->buildBaseSideNav(); $has_filter = array( 'action' => true, 'created' => true, 'subscribed' => true, 'triage' => true, 'projecttriage' => true, 'projectall' => true, ); $query = null; $key = $request->getStr('key'); if (!$key && !$this->view) { if ($this->getDefaultQuery()) { $key = $this->getDefaultQuery()->getQueryKey(); } } if ($key) { $query = id(new PhabricatorSearchQuery())->loadOneWhere( 'queryKey = %s', $key); } // If the user is running a saved query, load query parameters from that // query. Otherwise, build a new query object from the HTTP request. if ($query) { $nav->selectFilter('Q:'.$query->getQueryKey(), 'custom'); $this->view = 'custom'; } else { $this->view = $nav->selectFilter($this->view, 'action'); $query = $this->buildQueryFromRequest(); } // Execute the query. list($tasks, $handles, $total_count) = self::loadTasks( $query, $user); // Extract information we need to render the filters from the query. $search_text = $query->getParameter('fullTextSearch'); $user_phids = $query->getParameter('userPHIDs', array()); $task_ids = $query->getParameter('taskIDs', array()); $owner_phids = $query->getParameter('ownerPHIDs', array()); $author_phids = $query->getParameter('authorPHIDs', array()); $project_phids = $query->getParameter('projectPHIDs', array()); $any_project_phids = $query->getParameter( 'anyProjectPHIDs', array()); $any_user_project_phids = $query->getParameter( 'anyUserProjectPHIDs', array()); $exclude_project_phids = $query->getParameter( 'excludeProjectPHIDs', array()); $low_priority = $query->getParameter('lowPriority'); $high_priority = $query->getParameter('highPriority'); $page_size = $query->getParameter('limit'); $page = $query->getParameter('offset'); $q_status = $query->getParameter('status'); $q_group = $query->getParameter('group'); $q_order = $query->getParameter('order'); $form = id(new AphrontFormView()) ->setUser($user) ->setAction( $request->getRequestURI() ->alter('key', null) ->alter( $this->getStatusRequestKey(), $this->getStatusRequestValue($q_status)) ->alter( $this->getOrderRequestKey(), $this->getOrderRequestValue($q_order)) ->alter( $this->getGroupRequestKey(), $this->getGroupRequestValue($q_group))); if (isset($has_filter[$this->view])) { $tokens = array(); foreach ($user_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchowner/') ->setName('set_users') ->setLabel(pht('Users')) ->setValue($tokens)); } if ($this->view == 'custom') { $form->appendChild( id(new AphrontFormTextControl()) ->setName('set_search') ->setLabel(pht('Search')) ->setValue($search_text)); $form->appendChild( id(new AphrontFormTextControl()) ->setName('set_tasks') ->setLabel(pht('Task IDs')) ->setValue(join(',', $task_ids))); $tokens = array(); foreach ($owner_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchowner/') ->setName('set_owners') ->setLabel(pht('Owners')) ->setValue($tokens)); $tokens = array(); foreach ($author_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/authors/') ->setName('set_authors') ->setLabel(pht('Authors')) ->setValue($tokens)); } $tokens = array(); foreach ($project_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } if ($this->view != 'projectall' && $this->view != 'projecttriage') { $caption = null; if ($this->view == 'custom') { $caption = pht('Find tasks in ALL of these projects ("AND" query).'); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchproject/') ->setName('set_projects') ->setLabel(pht('Projects')) ->setCaption($caption) ->setValue($tokens)); } if ($this->view == 'custom') { $atokens = array(); foreach ($any_project_phids as $phid) { $atokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/projects/') ->setName('set_aprojects') ->setLabel(pht('Any Projects')) ->setCaption(pht('Find tasks in ANY of these projects ("OR" query).')) ->setValue($atokens)); $tokens = array(); foreach ($any_user_project_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/users/') ->setName('set_useraprojects') ->setLabel(pht('Any User Projects')) ->setCaption( pht('Find tasks in ANY of these users\' projects ("OR" query).')) ->setValue($tokens)); $tokens = array(); foreach ($exclude_project_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/projects/') ->setName('set_xprojects') ->setLabel(pht('Exclude Projects')) ->setCaption(pht('Find tasks NOT in any of these projects.')) ->setValue($tokens)); $priority = ManiphestTaskPriority::getLowestPriority(); if ($low_priority !== null) { $priority = $low_priority; } $form->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Min Priority')) ->setName('set_lpriority') ->setValue($priority) ->setOptions(array_reverse( ManiphestTaskPriority::getTaskPriorityMap(), true))); $priority = ManiphestTaskPriority::getHighestPriority(); if ($high_priority !== null) { $priority = $high_priority; } $form->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Max Priority')) ->setName('set_hpriority') ->setValue($priority) ->setOptions(ManiphestTaskPriority::getTaskPriorityMap())); } $form ->appendChild($this->renderStatusControl($q_status)) ->appendChild($this->renderGroupControl($q_group)) ->appendChild($this->renderOrderControl($q_order)); $submit = id(new AphrontFormSubmitControl()) ->setValue(pht('Filter Tasks')); // Only show "Save..." for novel queries which have some kind of query // parameters set. if ($this->view === 'custom' && empty($key) && $request->getRequestURI()->getQueryParams()) { $submit->addCancelButton( '/maniphest/custom/edit/?key='.$query->getQueryKey(), pht('Save Custom Query...')); } $form->appendChild($submit); $create_uri = new PhutilURI('/maniphest/task/create/'); if ($project_phids) { // If we have project filters selected, use them as defaults for task // creation. $create_uri->setQueryParam('projects', implode(';', $project_phids)); } $filter = new AphrontListFilterView(); if (empty($key)) { $filter->appendChild($form); } $have_tasks = false; foreach ($tasks as $group => $list) { if (count($list)) { $have_tasks = true; break; } } require_celerity_resource('maniphest-task-summary-css'); $list_container = new AphrontNullView(); $list_container->appendChild(hsprintf( '
')); if (!$have_tasks) { $no_tasks = pht('No matching tasks.'); $list_container->appendChild(hsprintf( '

'. '%s'. '

', $no_tasks)); $result_count = null; } else { $pager = new AphrontPagerView(); $pager->setURI($request->getRequestURI(), 'offset'); $pager->setPageSize($page_size); $pager->setOffset($page); $pager->setCount($total_count); $cur = ($pager->getOffset() + 1); $max = min($pager->getOffset() + $page_size, $total_count); $tot = $total_count; $results = pht('Displaying tasks %s - %s of %s.', number_format($cur), number_format($max), number_format($tot)); $result_count = phutil_tag( 'div', array( 'class' => 'maniphest-total-result-count' ), $results); $selector = new AphrontNullView(); $group = $query->getParameter('group'); $order = $query->getParameter('order'); $is_draggable = ($order == 'priority') && ($group == 'none' || $group == 'priority'); $lists = array(); foreach ($tasks as $group => $list) { $task_list = new ManiphestTaskListView(); $task_list->setShowBatchControls(true); if ($is_draggable) { $task_list->setShowSubpriorityControls(true); } $task_list->setUser($user); $task_list->setTasks($list); $task_list->setHandles($handles); $count = number_format(count($list)); $header = javelin_tag( 'h1', array( 'class' => 'maniphest-task-group-header', 'sigil' => 'task-group', 'meta' => array( 'priority' => head($list)->getPriority(), ), ), $group.' ('.$count.')'); $lists[] = phutil_tag( 'div', array( 'class' => 'maniphest-task-group' ), array( $header, $task_list, )); } $selector->appendChild($lists); $selector->appendChild($this->renderBatchEditor($query)); $list_container->appendChild($selector); $list_container->appendChild($pager); Javelin::initBehavior( 'maniphest-subpriority-editor', array( 'uri' => '/maniphest/subpriority/', )); } $nav->appendChild($filter); $nav->appendChild($result_count); $nav->appendChild($list_container); $title = pht('Task List'); $crumbs = $this->buildApplicationCrumbs() ->addCrumb( id(new PhabricatorCrumbView()) ->setName($title)) ->addAction( id(new PHUIListItemView()) ->setHref($this->getApplicationURI('/task/create/')) ->setName(pht('Create Task')) ->setIcon('create')); $nav->setCrumbs($crumbs); return $this->buildApplicationPage( $nav, array( 'title' => $title, 'device' => true, )); } public static function loadTasks( PhabricatorSearchQuery $search_query, PhabricatorUser $viewer) { $any_project = false; $search_text = $search_query->getParameter('fullTextSearch'); $user_phids = $search_query->getParameter('userPHIDs', array()); $task_ids = $search_query->getParameter('taskIDs', array()); $project_phids = $search_query->getParameter('projectPHIDs', array()); $any_project_phids = $search_query->getParameter( 'anyProjectPHIDs', array()); $any_user_project_phids = $search_query->getParameter( 'anyUserProjectPHIDs', array()); $xproject_phids = $search_query->getParameter( 'excludeProjectPHIDs', array()); $owner_phids = $search_query->getParameter('ownerPHIDs', array()); $author_phids = $search_query->getParameter('authorPHIDs', array()); $low_priority = $search_query->getParameter('lowPriority'); $low_priority = coalesce($low_priority, ManiphestTaskPriority::getLowestPriority()); $high_priority = $search_query->getParameter('highPriority'); $high_priority = coalesce($high_priority, ManiphestTaskPriority::getHighestPriority()); $query = new ManiphestTaskQuery(); + $query->setViewer($viewer); $query->withTaskIDs($task_ids); if ($project_phids) { $query->withAllProjects($project_phids); } if ($xproject_phids) { $query->withoutProjects($xproject_phids); } if ($any_project_phids) { $query->withAnyProjects($any_project_phids); } if ($owner_phids) { $query->withOwners($owner_phids); } if ($author_phids) { $query->withAuthors($author_phids); } if ($any_user_project_phids) { $query->setViewer($viewer); $query->withAnyUserProjects($any_user_project_phids); } $status = $search_query->getParameter('status', 'all'); if (!empty($status['open']) && !empty($status['closed'])) { $query->withStatus(ManiphestTaskQuery::STATUS_ANY); } else if (!empty($status['open'])) { $query->withStatus(ManiphestTaskQuery::STATUS_OPEN); } else { $query->withStatus(ManiphestTaskQuery::STATUS_CLOSED); } switch ($search_query->getParameter('view')) { case 'action': $query->withOwners($user_phids); break; case 'created': $query->withAuthors($user_phids); break; case 'subscribed': $query->withSubscribers($user_phids); break; case 'triage': $query->withOwners($user_phids); $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); break; case 'alltriage': $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); break; case 'all': break; case 'projecttriage': $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); break; case 'projectall': break; case 'custom': $query->withPrioritiesBetween($low_priority, $high_priority); break; } $query->withFullTextSearch($search_text); $order_map = array( 'priority' => ManiphestTaskQuery::ORDER_PRIORITY, 'created' => ManiphestTaskQuery::ORDER_CREATED, 'title' => ManiphestTaskQuery::ORDER_TITLE, ); $query->setOrderBy( idx( $order_map, $search_query->getParameter('order'), ManiphestTaskQuery::ORDER_MODIFIED)); $group_map = array( 'priority' => ManiphestTaskQuery::GROUP_PRIORITY, 'owner' => ManiphestTaskQuery::GROUP_OWNER, 'status' => ManiphestTaskQuery::GROUP_STATUS, 'project' => ManiphestTaskQuery::GROUP_PROJECT, ); $query->setGroupBy( idx( $group_map, $search_query->getParameter('group'), ManiphestTaskQuery::GROUP_NONE)); $query->setCalculateRows(true); $query->setLimit($search_query->getParameter('limit')); $query->setOffset($search_query->getParameter('offset')); $data = $query->execute(); $total_row_count = $query->getRowCount(); $project_group_phids = array(); if ($search_query->getParameter('group') == 'project') { foreach ($data as $task) { foreach ($task->getProjectPHIDs() as $phid) { $project_group_phids[] = $phid; } } } $handle_phids = mpull($data, 'getOwnerPHID'); $handle_phids = array_merge( $handle_phids, $project_phids, $user_phids, $xproject_phids, $owner_phids, $author_phids, $project_group_phids, $any_project_phids, $any_user_project_phids, array_mergev(mpull($data, 'getProjectPHIDs'))); $handles = id(new PhabricatorObjectHandleData($handle_phids)) ->setViewer($viewer) ->loadHandles(); switch ($search_query->getParameter('group')) { case 'priority': $data = mgroup($data, 'getPriority'); // If we have invalid priorities, they'll all map to "???". Merge // arrays to prevent them from overwriting each other. $out = array(); foreach ($data as $pri => $tasks) { $out[ManiphestTaskPriority::getTaskPriorityName($pri)][] = $tasks; } foreach ($out as $pri => $tasks) { $out[$pri] = array_mergev($tasks); } $data = $out; break; case 'status': $data = mgroup($data, 'getStatus'); $out = array(); foreach ($data as $status => $tasks) { $out[ManiphestTaskStatus::getTaskStatusFullName($status)] = $tasks; } $data = $out; break; case 'owner': $data = mgroup($data, 'getOwnerPHID'); $out = array(); foreach ($data as $phid => $tasks) { if ($phid) { $out[$handles[$phid]->getFullName()] = $tasks; } else { $out['Unassigned'] = $tasks; } } $data = $out; ksort($data); // Move "Unassigned" to the top of the list. if (isset($data['Unassigned'])) { $data = array('Unassigned' => $out['Unassigned']) + $out; } break; case 'project': $grouped = array(); foreach ($query->getGroupByProjectResults() as $project => $tasks) { foreach ($tasks as $task) { $group = $project ? $handles[$project]->getName() : 'No Project'; $grouped[$group][$task->getID()] = $task; } } $data = $grouped; ksort($data); // Move "No Project" to the end of the list. if (isset($data['No Project'])) { $noproject = $data['No Project']; unset($data['No Project']); $data += array('No Project' => $noproject); } break; default: $data = array( 'Tasks' => $data, ); break; } return array($data, $handles, $total_row_count); } private function renderBatchEditor(PhabricatorSearchQuery $search_query) { $user = $this->getRequest()->getUser(); Javelin::initBehavior( 'maniphest-batch-selector', array( 'selectAll' => 'batch-select-all', 'selectNone' => 'batch-select-none', 'submit' => 'batch-select-submit', 'status' => 'batch-select-status-cell', 'idContainer' => 'batch-select-id-container', 'formID' => 'batch-select-form', )); $select_all = javelin_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-all', ), pht('Select All')); $select_none = javelin_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-none', ), pht('Clear Selection')); $submit = phutil_tag( 'button', array( 'id' => 'batch-select-submit', 'disabled' => 'disabled', 'class' => 'disabled', ), pht("Batch Edit Selected \xC2\xBB")); $export = javelin_tag( 'a', array( 'href' => '/maniphest/export/'.$search_query->getQueryKey().'/', 'class' => 'grey button', ), pht('Export to Excel')); $hidden = phutil_tag( 'div', array( 'id' => 'batch-select-id-container', ), ''); $editor = hsprintf( '
'. '
%s
'. ''. ''. ''. ''. ''. ''. ''. '
%s%s%s%s%s%s
'. '
', pht('Batch Task Editor'), $select_all, $select_none, $export, '', $submit, $hidden); $editor = phabricator_form( $user, array( 'method' => 'POST', 'action' => '/maniphest/batch/', 'id' => 'batch-select-form', ), $editor); return $editor; } private function buildQueryFromRequest() { $request = $this->getRequest(); $user = $request->getUser(); $status = $this->getStatusValueFromRequest(); $group = $this->getGroupValueFromRequest(); $order = $this->getOrderValueFromRequest(); $user_phids = $request->getStrList( 'users', array($user->getPHID())); if ($this->view == 'projecttriage' || $this->view == 'projectall') { $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withMemberPHIDs($user_phids) ->execute(); $any_project_phids = mpull($projects, 'getPHID'); $any_user_project_phids = array(); } else { $any_project_phids = $request->getStrList('aprojects'); $any_user_project_phids = $request->getStrList('useraprojects'); } $project_phids = $request->getStrList('projects'); $exclude_project_phids = $request->getStrList('xprojects'); $task_ids = $request->getStrList('tasks'); if ($task_ids) { // We only need the integer portion of each task ID, so get rid of any // non-numeric elements $numeric_task_ids = array(); foreach ($task_ids as $task_id) { $task_id = preg_replace('/\D+/', '', $task_id); if (!empty($task_id)) { $numeric_task_ids[] = $task_id; } } if (empty($numeric_task_ids)) { $numeric_task_ids = array(null); } $task_ids = $numeric_task_ids; } $owner_phids = $request->getStrList('owners'); $author_phids = $request->getStrList('authors'); $search_string = $request->getStr('search'); $low_priority = $request->getInt('lpriority'); $high_priority = $request->getInt('hpriority'); $page = $request->getInt('offset'); $page_size = self::DEFAULT_PAGE_SIZE; $query = new PhabricatorSearchQuery(); $query->setQuery('<>'); $query->setParameters( array( 'fullTextSearch' => $search_string, 'view' => $this->view, 'userPHIDs' => $user_phids, 'projectPHIDs' => $project_phids, 'anyProjectPHIDs' => $any_project_phids, 'anyUserProjectPHIDs' => $any_user_project_phids, 'excludeProjectPHIDs' => $exclude_project_phids, 'ownerPHIDs' => $owner_phids, 'authorPHIDs' => $author_phids, 'taskIDs' => $task_ids, 'lowPriority' => $low_priority, 'highPriority' => $high_priority, 'group' => $group, 'order' => $order, 'offset' => $page, 'limit' => $page_size, 'status' => $status, )); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $query->save(); unset($unguarded); return $query; } /* -( Toggle Button Controls )--------------------------------------------- These are a giant mess since we have several different values: the request key (GET param used in requests), the request value (short names used in requests to keep URIs readable), and the query value (complex value stored in the query). */ private function getStatusValueFromRequest() { $map = $this->getStatusMap(); $val = $this->getRequest()->getStr($this->getStatusRequestKey()); return idx($map, $val, head($map)); } private function getGroupValueFromRequest() { $map = $this->getGroupMap(); $val = $this->getRequest()->getStr($this->getGroupRequestKey()); return idx($map, $val, head($map)); } private function getOrderValueFromRequest() { $map = $this->getOrderMap(); $val = $this->getRequest()->getStr($this->getOrderRequestKey()); return idx($map, $val, head($map)); } private function getStatusRequestKey() { return 's'; } private function getGroupRequestKey() { return 'g'; } private function getOrderRequestKey() { return 'o'; } private function getStatusRequestValue($value) { return array_search($value, $this->getStatusMap()); } private function getGroupRequestValue($value) { return array_search($value, $this->getGroupMap()); } private function getOrderRequestValue($value) { return array_search($value, $this->getOrderMap()); } private function getStatusMap() { return array( 'o' => array( 'open' => true, ), 'c' => array( 'closed' => true, ), 'oc' => array( 'open' => true, 'closed' => true, ), ); } private function getGroupMap() { return array( 'p' => 'priority', 'o' => 'owner', 's' => 'status', 'j' => 'project', 'n' => 'none', ); } private function getOrderMap() { return array( 'p' => 'priority', 'u' => 'updated', 'c' => 'created', 't' => 'title', ); } private function getStatusButtonMap() { return array( 'o' => pht('Open'), 'c' => pht('Closed'), 'oc' => pht('All'), ); } private function getGroupButtonMap() { return array( 'p' => pht('Priority'), 'o' => pht('Owner'), 's' => pht('Status'), 'j' => pht('Project'), 'n' => pht('None'), ); } private function getOrderButtonMap() { return array( 'p' => pht('Priority'), 'u' => pht('Updated'), 'c' => pht('Created'), 't' => pht('Title'), ); } public function renderStatusControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel(pht('Status')) ->setValue($this->getStatusRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getStatusRequestKey()) ->setButtons($this->getStatusButtonMap()); } public function renderOrderControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel(pht('Order')) ->setValue($this->getOrderRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getOrderRequestKey()) ->setButtons($this->getOrderButtonMap()); } public function renderGroupControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel(pht('Group')) ->setValue($this->getGroupRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getGroupRequestKey()) ->setButtons($this->getGroupButtonMap()); } } diff --git a/src/applications/project/controller/PhabricatorProjectProfileController.php b/src/applications/project/controller/PhabricatorProjectProfileController.php index 39b3144fcc..ff56d6bd7b 100644 --- a/src/applications/project/controller/PhabricatorProjectProfileController.php +++ b/src/applications/project/controller/PhabricatorProjectProfileController.php @@ -1,276 +1,277 @@ id = idx($data, 'id'); $this->page = idx($data, 'page'); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withIDs(array($this->id)) ->needMembers(true); $project = $query->executeOne(); $this->project = $project; if (!$project) { return new Aphront404Response(); } $profile = $project->loadProfile(); if (!$profile) { $profile = new PhabricatorProjectProfile(); } $picture = $profile->loadProfileImageURI(); require_celerity_resource('phabricator-profile-css'); $tasks = $this->renderTasksPage($project, $profile); $query = new PhabricatorFeedQuery(); $query->setFilterPHIDs( array( $project->getPHID(), )); $query->setLimit(50); $query->setViewer($this->getRequest()->getUser()); $stories = $query->execute(); $feed = $this->renderStories($stories); $people = $this->renderPeoplePage($project, $profile); $content = id(new AphrontMultiColumnView()) ->addColumn($people) ->addColumn($feed) ->setFluidLayout(true); $content = hsprintf( '
%s%s
', $tasks, $content); $header = id(new PhabricatorHeaderView()) ->setHeader($project->getName()) ->setSubheader(phutil_utf8_shorten($profile->getBlurb(), 1024)) ->setImage($picture); $actions = $this->buildActionListView($project); $properties = $this->buildPropertyListView($project); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addCrumb( id(new PhabricatorCrumbView()) ->setName($project->getName())); return $this->buildApplicationPage( array( $crumbs, $header, $actions, $properties, $content, ), array( 'title' => $project->getName(), 'device' => true, )); } private function renderPeoplePage( PhabricatorProject $project, PhabricatorProjectProfile $profile) { $member_phids = $project->getMemberPHIDs(); $handles = $this->loadViewerHandles($member_phids); $affiliated = array(); foreach ($handles as $phids => $handle) { $affiliated[] = phutil_tag('li', array(), $handle->renderLink()); } if ($affiliated) { $affiliated = phutil_tag('ul', array(), $affiliated); } else { $affiliated = hsprintf('

%s

', pht( 'No one is affiliated with this project.')); } return hsprintf( '
'. '

%s

'. '
%s
'. '
', pht('People'), $affiliated); } private function renderFeedPage( PhabricatorProject $project, PhabricatorProjectProfile $profile) { $query = new PhabricatorFeedQuery(); $query->setFilterPHIDs(array($project->getPHID())); $query->setViewer($this->getRequest()->getUser()); $query->setLimit(100); $stories = $query->execute(); if (!$stories) { return pht('There are no stories about this project.'); } return $this->renderStories($stories); } private function renderStories(array $stories) { assert_instances_of($stories, 'PhabricatorFeedStory'); $builder = new PhabricatorFeedBuilder($stories); $builder->setUser($this->getRequest()->getUser()); $builder->setShowHovercards(true); $view = $builder->buildView(); return hsprintf( '
'. '%s'. '
', $view->render()); } private function renderTasksPage( PhabricatorProject $project, PhabricatorProjectProfile $profile) { $user = $this->getRequest()->getUser(); $query = id(new ManiphestTaskQuery()) + ->setViewer($user) ->withAnyProjects(array($project->getPHID())) ->withStatus(ManiphestTaskQuery::STATUS_OPEN) ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) ->setLimit(10) ->setCalculateRows(true); $tasks = $query->execute(); $count = $query->getRowCount(); $phids = mpull($tasks, 'getOwnerPHID'); $phids = array_merge( $phids, array_mergev(mpull($tasks, 'getProjectPHIDs'))); $phids = array_filter($phids); $handles = $this->loadViewerHandles($phids); $task_list = new ManiphestTaskListView(); $task_list->setUser($user); $task_list->setTasks($tasks); $task_list->setHandles($handles); $open = number_format($count); $more_link = phutil_tag( 'a', array( 'href' => '/maniphest/view/all/?projects='.$project->getPHID(), ), pht("View All Open Tasks \xC2\xBB")); $content = hsprintf( '

%s

'. '
'. '%s'. ''. '
', pht('Open Tasks (%s)', $open), $task_list, $more_link); return $content; } private function buildActionListView(PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $request->getUser(); $id = $project->getID(); $view = id(new PhabricatorActionListView()) ->setUser($viewer) ->setObject($project) ->setObjectURI($request->getRequestURI()); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Project')) ->setIcon('edit') ->setHref($this->getApplicationURI("edit/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Members')) ->setIcon('edit') ->setHref($this->getApplicationURI("members/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $action = null; if (!$project->isUserMember($viewer->getPHID())) { $can_join = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_JOIN); $action = id(new PhabricatorActionView()) ->setUser($viewer) ->setRenderAsForm(true) ->setHref('/project/update/'.$project->getID().'/join/') ->setIcon('new') ->setDisabled(!$can_join) ->setName(pht('Join Project')); } else { $action = id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/update/'.$project->getID().'/leave/') ->setIcon('delete') ->setName(pht('Leave Project...')); } $view->addAction($action); return $view; } private function buildPropertyListView(PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $request->getUser(); $view = id(new PhabricatorPropertyListView()) ->setUser($viewer) ->setObject($project); $view->addProperty( pht('Created'), phabricator_datetime($project->getDateCreated(), $viewer)); return $view; } }