diff --git a/src/applications/doorkeeper/bridge/DoorkeeperBridgeGitHubIssue.php b/src/applications/doorkeeper/bridge/DoorkeeperBridgeGitHubIssue.php index 50dc28bf9d..0558b7d8f6 100644 --- a/src/applications/doorkeeper/bridge/DoorkeeperBridgeGitHubIssue.php +++ b/src/applications/doorkeeper/bridge/DoorkeeperBridgeGitHubIssue.php @@ -1,97 +1,107 @@ getObjectType() !== self::OBJTYPE_GITHUB_ISSUE) { return false; } return true; } public function pullRefs(array $refs) { $token = $this->getGitHubAccessToken(); if (!strlen($token)) { return null; } $template = id(new PhutilGitHubFuture()) ->setAccessToken($token); $futures = array(); $id_map = mpull($refs, 'getObjectID', 'getObjectKey'); foreach ($id_map as $key => $id) { list($user, $repository, $number) = $this->parseGitHubIssueID($id); $uri = "/repos/{$user}/{$repository}/issues/{$number}"; $data = array(); $futures[$key] = id(clone $template) ->setRawGitHubQuery($uri, $data); } $results = array(); $failed = array(); foreach (new FutureIterator($futures) as $key => $future) { try { $results[$key] = $future->resolve(); } catch (Exception $ex) { if (($ex instanceof HTTPFutureResponseStatus) && ($ex->getStatusCode() == 404)) { // TODO: Do we end up here for deleted objects and invisible // objects? } else { phlog($ex); $failed[$key] = $ex; } } } $viewer = $this->getViewer(); foreach ($refs as $ref) { $ref->setAttribute('name', pht('GitHub Issue %s', $ref->getObjectID())); $did_fail = idx($failed, $ref->getObjectKey()); if ($did_fail) { $ref->setSyncFailed(true); continue; } $result = idx($results, $ref->getObjectKey()); if (!$result) { continue; } $body = $result->getBody(); $ref->setIsVisible(true); $ref->setAttribute('api.raw', $body); $ref->setAttribute('name', $body['title']); $obj = $ref->getExternalObject(); - if ($obj->getID()) { - continue; - } $this->fillObjectFromData($obj, $result); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $obj->save(); unset($unguarded); } } public function fillObjectFromData(DoorkeeperExternalObject $obj, $result) { $body = $result->getBody(); $uri = $body['html_url']; $obj->setObjectURI($uri); + + $title = idx($body, 'title'); + $description = idx($body, 'body'); + + $created = idx($body, 'created_at'); + $created = strtotime($created); + + $state = idx($body, 'state'); + + $obj->setProperty('task.title', $title); + $obj->setProperty('task.description', $description); + $obj->setProperty('task.created', $created); + $obj->setProperty('task.state', $state); } } diff --git a/src/applications/nuance/github/NuanceGitHubRawEvent.php b/src/applications/nuance/github/NuanceGitHubRawEvent.php index 9e170bf092..2c0d62c7ca 100644 --- a/src/applications/nuance/github/NuanceGitHubRawEvent.php +++ b/src/applications/nuance/github/NuanceGitHubRawEvent.php @@ -1,204 +1,208 @@ type = $type; $event->raw = $raw; return $event; } public function getRepositoryFullName() { return $this->getRepositoryFullRawName(); } public function isIssueEvent() { if ($this->isPullRequestEvent()) { return false; } if ($this->type == self::TYPE_ISSUE) { return true; } switch ($this->getIssueRawKind()) { case 'IssuesEvent': return true; case 'IssueCommentEvent': if (!$this->getRawPullRequestData()) { return true; } break; } return false; } public function isPullRequestEvent() { if ($this->type == self::TYPE_ISSUE) { // TODO: This is wrong, some of these are pull events. return false; } $raw = $this->raw; switch ($this->getIssueRawKind()) { case 'PullRequestEvent': return true; case 'IssueCommentEvent': if ($this->getRawPullRequestData()) { return true; } break; } return false; } public function getIssueNumber() { if (!$this->isIssueEvent()) { return null; } return $this->getRawIssueNumber(); } public function getPullRequestNumber() { if (!$this->isPullRequestEvent()) { return null; } return $this->getRawIssueNumber(); } public function getID() { $raw = $this->raw; $id = idx($raw, 'id'); if ($id) { return (int)$id; } return null; } + public function getComment() { + return 'TODO: Actually extract comment text.'; + } + public function getURI() { $raw = $this->raw; if ($this->isIssueEvent() || $this->isPullRequestEvent()) { if ($this->type == self::TYPE_ISSUE) { $uri = idxv($raw, array('issue', 'html_url')); $uri = $uri.'#event-'.$this->getID(); } else { // The format of pull request events varies so we need to fish around // a bit to find the correct URI. $uri = idxv($raw, array('payload', 'pull_request', 'html_url')); $need_anchor = true; // For comments, we get a different anchor to link to the comment. In // this case, the URI comes with an anchor already. if (!$uri) { $uri = idxv($raw, array('payload', 'comment', 'html_url')); $need_anchor = false; } if (!$uri) { $uri = idxv($raw, array('payload', 'issue', 'html_url')); $need_anchor = true; } if ($need_anchor) { $uri = $uri.'#event-'.$this->getID(); } } } else { switch ($this->getIssueRawKind()) { case 'PushEvent': // These don't really have a URI since there may be multiple commits // involved and GitHub doesn't bundle the push as an object on its // own. Just try to find the URI for the log. The API also does // not return any HTML URI for these events. $head = idxv($raw, array('payload', 'head')); if ($head === null) { return null; } $repo = $this->getRepositoryFullRawName(); return "https://github.com/{$repo}/commits/{$head}"; case 'WatchEvent': // These have no reasonable URI. return null; default: return null; } } return $uri; } private function getRepositoryFullRawName() { $raw = $this->raw; $full = idxv($raw, array('repo', 'name')); if (strlen($full)) { return $full; } // For issue events, the repository is not identified explicitly in the // response body. Parse it out of the URI. $matches = null; $ok = preg_match( '(/repos/((?:[^/]+)/(?:[^/]+))/issues/events/)', idx($raw, 'url'), $matches); if ($ok) { return $matches[1]; } return null; } private function getIssueRawKind() { $raw = $this->raw; return idxv($raw, array('type')); } private function getRawIssueNumber() { $raw = $this->raw; if ($this->type == self::TYPE_ISSUE) { return idxv($raw, array('issue', 'number')); } if ($this->type == self::TYPE_REPOSITORY) { $issue_number = idxv($raw, array('payload', 'issue', 'number')); if ($issue_number) { return $issue_number; } $pull_number = idxv($raw, array('payload', 'number')); if ($pull_number) { return $pull_number; } } return null; } private function getRawPullRequestData() { $raw = $this->raw; return idxv($raw, array('payload', 'issue', 'pull_request')); } } diff --git a/src/applications/nuance/item/NuanceGitHubEventItemType.php b/src/applications/nuance/item/NuanceGitHubEventItemType.php index 3ee2e91489..1c6d90bbf3 100644 --- a/src/applications/nuance/item/NuanceGitHubEventItemType.php +++ b/src/applications/nuance/item/NuanceGitHubEventItemType.php @@ -1,246 +1,400 @@ getItemProperty('api.type'); switch ($api_type) { case 'issue': return $this->getGitHubIssueAPIEventDisplayName($item); case 'repository': return $this->getGitHubRepositoryAPIEventDisplayName($item); default: return pht('GitHub Event (Unknown API Type "%s")', $api_type); } } private function getGitHubIssueAPIEventDisplayName(NuanceItem $item) { $raw = $item->getItemProperty('api.raw', array()); $action = idxv($raw, array('event')); $number = idxv($raw, array('issue', 'number')); return pht('GitHub Issue #%d (%s)', $number, $action); } private function getGitHubRepositoryAPIEventDisplayName(NuanceItem $item) { $raw = $item->getItemProperty('api.raw', array()); $repo = idxv($raw, array('repo', 'name'), pht('')); $type = idx($raw, 'type'); switch ($type) { case 'PushEvent': $head = idxv($raw, array('payload', 'head')); $head = substr($head, 0, 8); $name = pht('Push %s', $head); break; case 'IssuesEvent': $action = idxv($raw, array('payload', 'action')); $number = idxv($raw, array('payload', 'issue', 'number')); $name = pht('Issue #%d (%s)', $number, $action); break; case 'IssueCommentEvent': $action = idxv($raw, array('payload', 'action')); $number = idxv($raw, array('payload', 'issue', 'number')); $name = pht('Issue #%d (Comment, %s)', $number, $action); break; case 'PullRequestEvent': $action = idxv($raw, array('payload', 'action')); $number = idxv($raw, array('payload', 'pull_request', 'number')); $name = pht('Pull Request #%d (%s)', $number, $action); break; default: $name = pht('Unknown Event ("%s")', $type); break; } return pht('GitHub %s %s', $repo, $name); } public function canUpdateItems() { return true; } protected function updateItemFromSource(NuanceItem $item) { $viewer = $this->getViewer(); $is_dirty = false; // TODO: Link up the requestor, etc. - $source = $item->getSource(); - $token = $source->getSourceProperty('github.token'); - $token = new PhutilOpaqueEnvelope($token); + $is_dirty = false; - $ref = $this->getDoorkeeperRef($item); - if ($ref) { - $ref = id(new DoorkeeperImportEngine()) - ->setViewer($viewer) - ->setRefs(array($ref)) - ->setThrowOnMissingLink(true) - ->setContextProperty('github.token', $token) - ->executeOne(); - - if ($ref->getSyncFailed()) { - $xobj = null; - } else { - $xobj = $ref->getExternalObject(); - } + $xobj = $this->reloadExternalObject($item); - if ($xobj) { - $item->setItemProperty('doorkeeper.xobj.phid', $xobj->getPHID()); - $is_dirty = true; - } + if ($xobj) { + $item->setItemProperty('doorkeeper.xobj.phid', $xobj->getPHID()); + $is_dirty = true; } if ($item->getStatus() == NuanceItem::STATUS_IMPORTING) { $item->setStatus(NuanceItem::STATUS_ROUTING); $is_dirty = true; } if ($is_dirty) { $item->save(); } } private function getDoorkeeperRef(NuanceItem $item) { $raw = $this->newRawEvent($item); $full_repository = $raw->getRepositoryFullName(); if (!strlen($full_repository)) { return null; } if ($raw->isIssueEvent()) { $ref_type = DoorkeeperBridgeGitHubIssue::OBJTYPE_GITHUB_ISSUE; $issue_number = $raw->getIssueNumber(); $full_ref = "{$full_repository}#{$issue_number}"; } else { return null; } return id(new DoorkeeperObjectRef()) ->setApplicationType(DoorkeeperBridgeGitHub::APPTYPE_GITHUB) ->setApplicationDomain(DoorkeeperBridgeGitHub::APPDOMAIN_GITHUB) ->setObjectType($ref_type) ->setObjectID($full_ref); } + private function reloadExternalObject(NuanceItem $item, $local = false) { + $ref = $this->getDoorkeeperRef($item); + if (!$ref) { + return null; + } + + $source = $item->getSource(); + $token = $source->getSourceProperty('github.token'); + $token = new PhutilOpaqueEnvelope($token); + + $viewer = $this->getViewer(); + + $ref = id(new DoorkeeperImportEngine()) + ->setViewer($viewer) + ->setRefs(array($ref)) + ->setThrowOnMissingLink(true) + ->setContextProperty('github.token', $token) + ->needLocalOnly($local) + ->executeOne(); + + if ($ref->getSyncFailed()) { + $xobj = null; + } else { + $xobj = $ref->getExternalObject(); + } + + if ($xobj) { + $this->externalObject = $xobj; + } + + return $xobj; + } + + private function getExternalObject(NuanceItem $item) { + if ($this->externalObject === null) { + $xobj = $this->reloadExternalObject($item, $local = true); + if ($xobj) { + $this->externalObject = $xobj; + } else { + $this->externalObject = false; + } + } + + if ($this->externalObject) { + return $this->externalObject; + } + + return null; + } + private function newRawEvent(NuanceItem $item) { $type = $item->getItemProperty('api.type'); $raw = $item->getItemProperty('api.raw', array()); return NuanceGitHubRawEvent::newEvent($type, $raw); } public function getItemActions(NuanceItem $item) { $actions = array(); + $xobj = $this->getExternalObject($item); + if ($xobj) { + $actions[] = $this->newItemAction($item, 'reload') + ->setName(pht('Reload from GitHub')) + ->setIcon('fa-refresh') + ->setWorkflow(true) + ->setRenderAsForm(true); + } + $actions[] = $this->newItemAction($item, 'sync') ->setName(pht('Import to Maniphest')) ->setIcon('fa-anchor') ->setWorkflow(true) ->setRenderAsForm(true); $actions[] = $this->newItemAction($item, 'raw') ->setName(pht('View Raw Event')) ->setWorkflow(true) ->setIcon('fa-code'); return $actions; } protected function handleAction(NuanceItem $item, $action) { $viewer = $this->getViewer(); $controller = $this->getController(); switch ($action) { case 'raw': $raw = array( 'api.type' => $item->getItemProperty('api.type'), 'api.raw' => $item->getItemProperty('api.raw'), ); $raw_output = id(new PhutilJSON())->encodeFormatted($raw); $raw_box = id(new AphrontFormTextAreaControl()) ->setCustomClass('PhabricatorMonospaced') ->setLabel(pht('Raw Event')) ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_TALL) ->setValue($raw_output); $form = id(new AphrontFormView()) ->appendChild($raw_box); return $controller->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle(pht('GitHub Raw Event')) ->appendForm($form) ->addCancelButton($item->getURI(), pht('Done')); case 'sync': + case 'reload': $item->issueCommand($viewer->getPHID(), $action); return id(new AphrontRedirectResponse())->setURI($item->getURI()); } return null; } protected function newItemView(NuanceItem $item) { $content = array(); $content[] = $this->newGitHubEventItemPropertyBox($item); return $content; } private function newGitHubEventItemPropertyBox($item) { $viewer = $this->getViewer(); $property_list = id(new PHUIPropertyListView()) ->setViewer($viewer); $event = $this->newRawEvent($item); $property_list->addProperty( pht('GitHub Event ID'), $event->getID()); $event_uri = $event->getURI(); if ($event_uri && PhabricatorEnv::isValidRemoteURIForLink($event_uri)) { $event_uri = phutil_tag( 'a', array( 'href' => $event_uri, ), $event_uri); } if ($event_uri) { $property_list->addProperty( pht('GitHub Event URI'), $event_uri); } return id(new PHUIObjectBoxView()) ->setHeaderText(pht('Event Properties')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($property_list); } - protected function handleCommand(NuanceItem $item, $action) { + protected function handleCommand( + NuanceItem $item, + NuanceItemCommand $command) { + + $action = $command->getCommand(); + switch ($action) { + case 'sync': + return $this->syncItem($item, $command); + case 'reload': + $this->reloadExternalObject($item); + return true; + } + return null; } + private function syncItem( + NuanceItem $item, + NuanceItemCommand $command) { + + $xobj_phid = $item->getItemProperty('doorkeeper.xobj.phid'); + if (!$xobj_phid) { + throw new Exception( + pht( + 'Unable to sync: no external object PHID.')); + } + + // TODO: Write some kind of marker to prevent double-synchronization. + + $viewer = $this->getViewer(); + + $xobj = id(new DoorkeeperExternalObjectQuery()) + ->setViewer($viewer) + ->withPHIDs(array($xobj_phid)) + ->executeOne(); + if (!$xobj) { + throw new Exception( + pht( + 'Unable to sync: failed to load object "%s".', + $xobj_phid)); + } + + $nuance_phid = id(new PhabricatorNuanceApplication())->getPHID(); + + $xactions = array(); + + $task = id(new ManiphestTaskQuery()) + ->setViewer($viewer) + ->withBridgedObjectPHIDs(array($xobj_phid)) + ->executeOne(); + if (!$task) { + $task = ManiphestTask::initializeNewTask($viewer) + ->setAuthorPHID($nuance_phid) + ->setBridgedObjectPHID($xobj_phid); + + $title = $xobj->getProperty('task.title'); + if (!strlen($title)) { + $title = pht('Nuance Item %d Task', $item->getID()); + } + + $description = $xobj->getProperty('task.description'); + $created = $xobj->getProperty('task.created'); + $state = $xobj->getProperty('task.state'); + + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(ManiphestTransaction::TYPE_TITLE) + ->setNewValue($title) + ->setDateCreated($created); + + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(ManiphestTransaction::TYPE_DESCRIPTION) + ->setNewValue($description) + ->setDateCreated($created); + + $task->setDateCreated($created); + + // TODO: Synchronize state. + } + + $event = $this->newRawEvent($item); + $comment = $event->getComment(); + if (strlen($comment)) { + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) + ->attachComment( + id(new ManiphestTransactionComment()) + ->setContent($comment)); + } + + // TODO: Preserve the item's original source. + $source = PhabricatorContentSource::newForSource( + PhabricatorContentSource::SOURCE_DAEMON, + array()); + + // TOOD: This should really be the external source. + $acting_phid = $nuance_phid; + + $editor = id(new ManiphestTransactionEditor()) + ->setActor($viewer) + ->setActingAsPHID($acting_phid) + ->setContentSource($source) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $xactions = $editor->applyTransactions($task, $xactions); + + return array( + 'objectPHID' => $task->getPHID(), + 'xactionPHIDs' => mpull($xactions, 'getPHID'), + ); + } + } diff --git a/src/applications/nuance/item/NuanceItemType.php b/src/applications/nuance/item/NuanceItemType.php index 144d6e86f5..e8397b13f8 100644 --- a/src/applications/nuance/item/NuanceItemType.php +++ b/src/applications/nuance/item/NuanceItemType.php @@ -1,138 +1,140 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setController(PhabricatorController $controller) { $this->controller = $controller; return $this; } public function getController() { return $this->controller; } public function canUpdateItems() { return false; } final public function buildItemView(NuanceItem $item) { return $this->newItemView($item); } protected function newItemView(NuanceItem $item) { return null; } public function getItemTypeDisplayIcon() { return null; } public function getItemActions(NuanceItem $item) { return array(); } abstract public function getItemTypeDisplayName(); abstract public function getItemDisplayName(NuanceItem $item); final public function updateItem(NuanceItem $item) { if (!$this->canUpdateItems()) { throw new Exception( pht( 'This item type ("%s", of class "%s") can not update items.', $this->getItemTypeConstant(), get_class($this))); } $this->updateItemFromSource($item); } protected function updateItemFromSource(NuanceItem $item) { throw new PhutilMethodNotImplementedException(); } final public function getItemTypeConstant() { return $this->getPhobjectClassConstant('ITEMTYPE', 64); } final public static function getAllItemTypes() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getItemTypeConstant') ->execute(); } final protected function newItemAction(NuanceItem $item, $key) { $id = $item->getID(); $action_uri = "/nuance/item/action/{$id}/{$key}/"; return id(new PhabricatorActionView()) ->setHref($action_uri); } final public function buildActionResponse(NuanceItem $item, $action) { $response = $this->handleAction($item, $action); if ($response === null) { return new Aphront404Response(); } return $response; } protected function handleAction(NuanceItem $item, $action) { return null; } final public function applyCommand( NuanceItem $item, NuanceItemCommand $command) { $result = $this->handleCommand($item, $command); if ($result === null) { return; } $xaction = id(new NuanceItemTransaction()) ->setTransactionType(NuanceItemTransaction::TYPE_COMMAND) ->setNewValue( array( 'command' => $command->getCommand(), 'parameters' => $command->getParameters(), 'result' => $result, )); $viewer = $this->getViewer(); // TODO: Maybe preserve the actor's original content source? $source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_DAEMON, array()); $editor = id(new NuanceItemEditor()) ->setActor($viewer) ->setActingAsPHID($command->getAuthorPHID()) ->setContentSource($source) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true) ->applyTransactions($item, array($xaction)); } - protected function handleCommand(NuanceItem $item, $action) { + protected function handleCommand( + NuanceItem $item, + NuanceItemCommand $command) { return null; } }