diff --git a/docs/en/configuring.md b/docs/en/configuring.md index 2624867e..8ae10fca 100644 --- a/docs/en/configuring.md +++ b/docs/en/configuring.md @@ -36,6 +36,12 @@ php-censor: host: localhost name: php-censor-queue lifetime: 600 + bitbucket: + username: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + app_password: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + comments: + commit: false # This option allow/deny to post comments to Bitbucket commit + pull_request: false # This option allow/deny to post comments to Bitbucket Pull Request github: token: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' comments: diff --git a/src/PHPCensor/Command/InstallCommand.php b/src/PHPCensor/Command/InstallCommand.php index 8e8aa0d4..efa11393 100644 --- a/src/PHPCensor/Command/InstallCommand.php +++ b/src/PHPCensor/Command/InstallCommand.php @@ -261,6 +261,14 @@ class InstallCommand extends Command 'smtp_password' => null, 'smtp_encryption' => false, ], + 'bitbucket' => [ + 'username' => null, + 'app_password' => null, + 'comments' => [ + 'commit' => false, + 'pull_request' => false, + ], + ], 'github' => [ 'token' => null, 'comments' => [ diff --git a/src/PHPCensor/Controller/WebhookController.php b/src/PHPCensor/Controller/WebhookController.php index d2603a0b..c9016682 100644 --- a/src/PHPCensor/Controller/WebhookController.php +++ b/src/PHPCensor/Controller/WebhookController.php @@ -90,21 +90,32 @@ class WebhookController extends Controller $payload = json_decode(file_get_contents("php://input"), true); - if (empty($payload['push']['changes'])) { - // Invalid event from bitbucket - return [ - 'status' => 'failed', - 'commits' => [] - ]; + // Handle Pull Request webhooks: + if (!empty($payload['pullrequest'])) { + return $this->bitbucketPullRequest($project, $payload); } - return $this->bitbucketWebhook($payload, $project); + // Handle Push (and Tag) webhooks: + if (!empty($payload['push']['changes'])) { + return $this->bitbucketCommitRequest($project, $payload); + } + + // Invalid event from bitbucket + return [ + 'status' => 'failed', + 'commits' => [] + ]; } /** - * Bitbucket webhooks. + * Handle the payload when Bitbucket sends a commit webhook. + * + * @param Project $project + * @param array $payload + * + * @return array */ - protected function bitbucketWebhook($payload, $project) + protected function bitbucketCommitRequest(Project $project, array $payload) { $results = []; $status = 'failed'; @@ -134,6 +145,92 @@ class WebhookController extends Controller return ['status' => $status, 'commits' => $results]; } + /** + * Handle the payload when Bitbucket sends a Pull Request webhook. + * + * @param Project $project + * @param array $payload + * + * @return array + * + * @throws Exception + */ + protected function bitbucketPullRequest(Project $project, array $payload) + { + // We only want to know about open pull requests: + if (!in_array($_SERVER['HTTP_X_EVENT_KEY'], ['pullrequest:created', 'pullrequest:updated'])) { + return ['status' => 'ok']; + } + + $headers = []; + $username = Config::getInstance()->get('php-censor.bitbucket.username'); + $appPassword = Config::getInstance()->get('php-censor.bitbucket.app_password'); + + if (empty($username) || empty($appPassword)) { + throw new Exception('Please provide Username and App Password of your Bitbucket account.'); + } + + $commitsUrl = $payload['pullrequest']['links']['commits']['href']; + + $client = new Client(); + $commitsResponse = $client->get($commitsUrl, [ + 'auth' => [$username, $appPassword], + ]); + $httpStatus = (integer)$commitsResponse->getStatusCode(); + + // Check we got a success response: + if ($httpStatus < 200 || $httpStatus >= 300) { + throw new Exception('Could not get commits, failed API request.'); + } + + $results = []; + $status = 'failed'; + $commits = json_decode($commitsResponse->getBody(), true)['values']; + foreach ($commits as $commit) { + // Skip all but the current HEAD commit ID: + $id = $commit['hash']; + if (strpos($id, $payload['pullrequest']['source']['commit']['hash']) !== 0) { + $results[$id] = ['status' => 'ignored', 'message' => 'not branch head']; + continue; + } + + try { + $branch = $payload['pullrequest']['destination']['branch']['name']; + $committer = $commit['author']['raw']; + if (strpos($committer, '>') !== false) { + // In order not to loose email if it is RAW, w/o "<>" symbols + $committer = substr($committer, 0, strpos($committer, '>')); + $committer = substr($committer, strpos($committer, '<') + 1); + } + $message = $commit['message']; + + $extra = [ + 'build_type' => 'pull_request', + 'pull_request_number' => $payload['pullrequest']['id'], + 'remote_branch' => $payload['pullrequest']['source']['branch']['name'], + 'remote_reference' => $payload['pullrequest']['source']['repository']['full_name'], + ]; + + $results[$id] = $this->createBuild($project, $id, $branch, null, $committer, $message, $extra); + $status = 'ok'; + } catch (Exception $ex) { + $results[$id] = ['status' => 'failed', 'error' => $ex->getMessage()]; + } + } + + return ['status' => $status, 'commits' => $results]; + } + + /** + * Bitbucket webhooks. + * + * @deprecated, for BC purpose + */ + protected function bitbucketWebhook($payload, $project) + { + return $this->bitbucketCommitRequest($project, $payload); + } + /** * Bitbucket POST service. */ diff --git a/src/PHPCensor/Helper/Bitbucket.php b/src/PHPCensor/Helper/Bitbucket.php new file mode 100644 index 00000000..e585816b --- /dev/null +++ b/src/PHPCensor/Helper/Bitbucket.php @@ -0,0 +1,108 @@ +get('php-censor.bitbucket.username'); + $appPassword = Config::getInstance()->get('php-censor.bitbucket.app_password'); + + if (empty($username) || empty($appPassword)) { + return; + } + + $url = '/1.0/repositories/' . $repo . '/pullrequests/' . $pullId . '/comments/'; + $client = new Client(['base_uri' => 'https://api.bitbucket.org']); + $response = $client->post($url, [ + 'auth' => [$username, $appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'content' => $comment, + 'anchor' => substr($commitId, 0, 12), + 'filename' => $file, + 'line_to' => $line, + ], + ]); + } + + /** + * Create a comment on a Bitbucket commit. + * + * @param $repo + * @param $commitId + * @param $file + * @param $line + * @param $comment + * @return null + */ + public function createCommitComment($repo, $commitId, $file, $line, $comment) + { + $username = Config::getInstance()->get('php-censor.bitbucket.username'); + $appPassword = Config::getInstance()->get('php-censor.bitbucket.app_password'); + + if (empty($username) || empty($appPassword)) { + return; + } + + $url = '/1.0/repositories/' . $repo . '/changesets/' . $commitId . '/comments'; + + $client = new Client(['base_uri' => 'https://api.bitbucket.org']); + $response = $client->post($url, [ + 'auth' => [$username, $appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'content' => $comment, + 'filename' => $file, + 'line_to' => $line, + ], + ]); + } + + /** + * @param string $repo + * @param int $pullRequestId + * + * @return string + */ + public function getPullRequestDiff($repo, $pullRequestId) + { + $username = Config::getInstance()->get('php-censor.bitbucket.username'); + $appPassword = Config::getInstance()->get('php-censor.bitbucket.app_password'); + + if (empty($username) || empty($appPassword)) { + return; + } + + $url = '/2.0/repositories/' . $repo . '/pullrequests/' . $pullRequestId . '/diff'; + + $client = new Client(['base_uri' => 'https://api.bitbucket.org']); + + $response = $client->get($url, ['auth' => [$username, $appPassword]]); + + return (string)$response->getBody(); + } +} diff --git a/src/PHPCensor/Model/Build/BitbucketBuild.php b/src/PHPCensor/Model/Build/BitbucketBuild.php index 2071a4aa..3b602fbf 100644 --- a/src/PHPCensor/Model/Build/BitbucketBuild.php +++ b/src/PHPCensor/Model/Build/BitbucketBuild.php @@ -2,6 +2,13 @@ namespace PHPCensor\Model\Build; +use GuzzleHttp\Client; +use PHPCensor\Builder; +use PHPCensor\Helper\Bitbucket; +use PHPCensor\Helper\Diff; +use b8\Config; +use PHPCensor\Model\BuildError; + /** * BitBucket Build Model * @@ -25,6 +32,91 @@ class BitbucketBuild extends RemoteGitBuild return 'https://bitbucket.org/' . $this->getProject()->getReference() . '/src/?at=' . $this->getBranch(); } + /** + * Get link to tag from another source (i.e. BitBucket) + */ + public function getTagLink() + { + return 'https://bitbucket.org/' . $this->getProject()->getReference() . '/src/?at=' . $this->getTag(); + } + + /** + * Send status updates to any relevant third parties (i.e. Bitbucket) + * + * @return bool + */ + public function sendStatusPostback() + { + if ('Manual' === $this->getCommitId()) { + return false; + } + + $project = $this->getProject(); + + if (empty($project)) { + return false; + } + + $username = Config::getInstance()->get('php-censor.bitbucket.username'); + $appPassword = Config::getInstance()->get('php-censor.bitbucket.app_password'); + + if (empty($username) || empty($appPassword) || empty($this->data['id'])) { + return false; + } + + switch ($this->getStatus()) { + case 0: + case 1: + $status = 'INPROGRESS'; + $description = 'PHP Censor build running.'; + break; + case 2: + $status = 'SUCCESSFUL'; + $description = 'PHP Censor build passed.'; + break; + case 3: + $status = 'FAILED'; + $description = 'PHP Censor build failed.'; + break; + default: + $status = 'STOPPED'; + $description = 'PHP Censor build failed to complete.'; + break; + } + + $phpCensorUrl = Config::getInstance()->get('php-censor.url'); + + $url = sprintf( + '/2.0/repositories/%s/commit/%s/statuses/build', + $this->getExtra('build_type') == 'pull_request' + ? $this->getExtra('remote_reference') + : $project->getReference(), + $this->getCommitId() + ); + + $client = new Client([ + 'base_uri' => 'https://api.bitbucket.org', + 'http_errors' => false, + ]); + $response = $client->post($url, [ + 'auth' => [$username, $appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'state' => $status, + 'key' => 'PHP-CENSOR', + 'url' => $phpCensorUrl . '/build/view/' . $this->getId(), + 'name' => 'PHP Censor Build #' . $this->getId(), + 'description' => $description, + ], + ]); + + $status = (integer)$response->getStatusCode(); + + return ($status >= 200 && $status < 300); + } + /** * Get the URL to be used to clone this remote repository. */ @@ -49,10 +141,7 @@ class BitbucketBuild extends RemoteGitBuild $reference = $this->getProject()->getReference(); if ($this->getExtra('build_type') == 'pull_request') { - $matches = []; - preg_match('/[\/:]([a-zA-Z0-9_\-]+\/[a-zA-Z0-9_\-]+)/', $this->getExtra('remote_url'), $matches); - - $reference = $matches[1]; + $reference = $this->getExtra('remote_reference'); } $link = 'https://bitbucket.org/' . $reference . '/'; @@ -62,4 +151,138 @@ class BitbucketBuild extends RemoteGitBuild return $link; } + + /** + * Handle any post-clone tasks, like applying a pull request patch on top of the branch. + * @param Builder $builder + * @param $cloneTo + * @param array $extra + * @return bool + */ + protected function postCloneSetup(Builder $builder, $cloneTo, array $extra = null) + { + $buildType = $this->getExtra('build_type'); + + $success = true; + $skipGitFinalization = false; + + try { + if (!empty($buildType) && $buildType == 'pull_request') { + $helper = new Bitbucket(); + $diff = $helper->getPullRequestDiff( + $this->getProject()->getReference(), + $this->getExtra('pull_request_number') + ); + + $diffFile = $this->writeDiff($builder->buildPath, $diff); + + $cmd = 'cd "%s" && git checkout -b php-censor/' . $this->getId() . ' && git apply "%s"'; + + $success = $builder->executeCommand($cmd, $cloneTo, $diffFile); + + unlink($diffFile); + $skipGitFinalization = true; + } + } catch (\Exception $ex) { + $success = false; + } + + if ($success && !$skipGitFinalization) { + $success = parent::postCloneSetup($builder, $cloneTo, $extra); + } + + return $success; + } + + /** + * Create an diff file on disk for this build. + * + * @param string $cloneTo + * + * @return string + */ + protected function writeDiff($cloneTo, $diff) + { + $filePath = dirname($cloneTo . '/temp'); + $diffFile = $filePath . '.patch'; + + file_put_contents($diffFile, $diff); + chmod($diffFile, 0600); + + return $diffFile; + } + + /** + * @inheritDoc + */ + public function reportError( + Builder $builder, + $plugin, + $message, + $severity = BuildError::SEVERITY_NORMAL, + $file = null, + $lineStart = null, + $lineEnd = null + ) { + $allowCommentCommit = (boolean)Config::getInstance()->get('php-censor.bitbucket.comments.commit', false); + $allowCommentPullRequest = (boolean)Config::getInstance()->get('php-censor.bitbucket.comments.pull_request', false); + //$file = $builder->buildPath.'test.php'; + if ($allowCommentCommit || $allowCommentPullRequest) { + $diffLineNumber = $this->getDiffLineNumber($builder, $file, $lineStart); + + if (!is_null($diffLineNumber)) { + $helper = new Bitbucket(); + + $repo = $this->getProject()->getReference(); + $prNumber = $this->getExtra('pull_request_number'); + $commit = $this->getCommitId(); + + if (!empty($prNumber)) { + if ($allowCommentPullRequest) { + $helper->createPullRequestComment($repo, $prNumber, $commit, $file, $lineStart, $message); + } + } else { + if ($allowCommentCommit) { + $helper->createCommitComment($repo, $commit, $file, $lineStart, $message); + } + } + } + } + + parent::reportError($builder, $plugin, $message, $severity, $file, $lineStart, $lineEnd); + } + + /** + * Uses git diff to figure out what the diff line position is, based on the error line number. + * @param Builder $builder + * @param $file + * @param $line + * @return int|null + */ + protected function getDiffLineNumber(Builder $builder, $file, $line) + { + $line = (integer)$line; + + $builder->logExecOutput(false); + + $prNumber = $this->getExtra('pull_request_number'); + $path = $builder->buildPath; + + if (!empty($prNumber)) { + $builder->executeCommand('cd %s && git diff origin/%s "%s"', $path, $this->getBranch(), $file); + } else { + $commitId = $this->getCommitId(); + $compare = $commitId == 'Manual' ? 'HEAD' : $commitId; + $builder->executeCommand('cd %s && git diff %s^^ "%s"', $path, $compare, $file); + } + + $builder->logExecOutput(true); + + $diff = $builder->getLastOutput(); + + $helper = new Diff(); + $lines = $helper->getLinePositions($diff); + + return isset($lines[$line]) ? $lines[$line] : null; + } }