WebhookController::githubCommitRequest()   C
last analyzed

Complexity

Conditions 9
Paths 9

Size

Total Lines 49
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

Changes 2
Bugs 1 Features 0
Metric Value
c 2
b 1
f 0
dl 0
loc 49
ccs 0
cts 30
cp 0
rs 5.7446
cc 9
eloc 29
nc 9
nop 2
crap 90
1
<?php
2
/**
3
 * PHPCI - Continuous Integration for PHP.
4
 *
5
 * @copyright    Copyright 2014-2015, Block 8 Limited.
6
 * @license      https://github.com/Block8/PHPCI/blob/master/LICENSE.md
7
 *
8
 * @link         https://www.phptesting.org/
9
 */
10
11
namespace PHPCI\Controller;
12
13
use b8;
14
use b8\Store;
15
use Exception;
16
use PHPCI\Model\Project;
17
use PHPCI\Service\BuildService;
18
use PHPCI\Store\BuildStore;
19
use PHPCI\Store\ProjectStore;
20
21
/**
22
 * Webhook Controller - Processes webhook pings from BitBucket, Github, Gitlab, etc.
23
 *
24
 * @author       Dan Cryer <[email protected]>
25
 * @author       Sami Tikka <[email protected]>
26
 * @author       Alex Russell <[email protected]>
27
 * @author       Guillaume Perréal <[email protected]>
28
 */
29
class WebhookController extends \b8\Controller
30
{
31
    /**
32
     * @var BuildStore
33
     */
34
    protected $buildStore;
35
36
    /**
37
     * @var ProjectStore
38
     */
39
    protected $projectStore;
40
41
    /**
42
     * @var BuildService
43
     */
44
    protected $buildService;
45
46
    /**
47
     * Initialise the controller, set up stores and services.
48
     */
49
    public function init()
50
    {
51
        $this->buildStore = Store\Factory::getStore('Build');
52
        $this->projectStore = Store\Factory::getStore('Project');
53
        $this->buildService = new BuildService($this->buildStore);
0 ignored issues
show
Compatibility introduced by
$this->buildStore of type object<b8\Store> is not a sub-type of object<PHPCI\Store\BuildStore>. It seems like you assume a child class of the class b8\Store to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
54
    }
55
56
    /** Handle the action, Ensuring to return a JsonResponse.
57
     * @param string $action
58
     * @param mixed  $actionParams
59
     *
60
     * @return \b8\Http\Response
61
     */
62
    public function handleAction($action, $actionParams)
63
    {
64
        $response = new b8\Http\Response\JsonResponse();
65
        try {
66
            $data = parent::handleAction($action, $actionParams);
67
            if (isset($data['responseCode'])) {
68
                $response->setResponseCode($data['responseCode']);
69
                unset($data['responseCode']);
70
            }
71
            $response->setContent($data);
72
        } catch (Exception $ex) {
73
            $response->setResponseCode(500);
74
            $response->setContent(array('status' => 'failed', 'error' => $ex->getMessage()));
75
        }
76
77
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $response; (b8\Http\Response\JsonResponse) is incompatible with the return type of the parent method b8\Controller::handleAction of type b8\b8\Http\Response.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
78
    }
79
80
    /**
81
     * Called by Bitbucket POST service.
82
     */
83
    public function bitbucket($projectId)
84
    {
85
        $project = $this->fetchProject($projectId, 'bitbucket');
86
        $payload = json_decode($this->getParam('payload'), true);
87
88
        $results = array();
89
        $status = 'failed';
90
        foreach ($payload['commits'] as $commit) {
91
            try {
92
                $email = $commit['raw_author'];
93
                $email = substr($email, 0, strpos($email, '>'));
94
                $email = substr($email, strpos($email, '<') + 1);
95
96
                $results[$commit['raw_node']] = $this->createBuild(
97
                    $project,
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($projectId, 'bitbucket') on line 85 can be null; however, PHPCI\Controller\WebhookController::createBuild() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
98
                    $commit['raw_node'],
99
                    $commit['branch'],
100
                    $email,
101
                    $commit['message']
102
                );
103
                $status = 'ok';
104
            } catch (Exception $ex) {
105
                $results[$commit['raw_node']] = array('status' => 'failed', 'error' => $ex->getMessage());
106
            }
107
        }
108
109
        return array('status' => $status, 'commits' => $results);
110
    }
111
112
    /**
113
     * Called by POSTing to /webhook/git/<project_id>?branch=<branch>&commit=<commit>.
114
     *
115
     * @param string $projectId
116
     */
117
    public function git($projectId)
118
    {
119
        $project = $this->fetchProject($projectId, array('local', 'remote'));
120
        $branch = $this->getParam('branch', $project->getBranch());
121
        $commit = $this->getParam('commit');
122
        $commitMessage = $this->getParam('message');
123
        $committer = $this->getParam('committer');
124
125
        return $this->createBuild($project, $commit, $branch, $committer, $commitMessage);
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($pro...ray('local', 'remote')) on line 119 can be null; however, PHPCI\Controller\WebhookController::createBuild() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
126
    }
127
128
    /**
129
     * Called by Github Webhooks:.
130
     */
131
    public function github($projectId)
0 ignored issues
show
Coding Style introduced by
github uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
132
    {
133
        $project = $this->fetchProject($projectId, 'github');
134
135
        switch ($_SERVER['CONTENT_TYPE']) {
136
            case 'application/json':
137
                $payload = json_decode(file_get_contents('php://input'), true);
138
                break;
139
            case 'application/x-www-form-urlencoded':
140
                $payload = json_decode($this->getParam('payload'), true);
141
                break;
142
            default:
143
                return array('status' => 'failed', 'error' => 'Content type not supported.', 'responseCode' => 401);
144
        }
145
146
        // Handle Pull Request web hooks:
147
        if (array_key_exists('pull_request', $payload)) {
148
            return $this->githubPullRequest($project, $payload);
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($projectId, 'github') on line 133 can be null; however, PHPCI\Controller\Webhook...er::githubPullRequest() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
149
        }
150
151
        // Handle Push web hooks:
152
        if (array_key_exists('commits', $payload)) {
153
            return $this->githubCommitRequest($project, $payload);
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($projectId, 'github') on line 133 can be null; however, PHPCI\Controller\Webhook...::githubCommitRequest() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
154
        }
155
156
        return array('status' => 'ignored', 'message' => 'Unusable payload.');
157
    }
158
159
    /**
160
     * Handle the payload when Github sends a commit webhook.
161
     *
162
     * @param Project                       $project
163
     * @param array                         $payload
164
     * @param b8\Http\Response\JsonResponse $response
0 ignored issues
show
Bug introduced by
There is no parameter named $response. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
165
     *
166
     * @return b8\Http\Response\JsonResponse
167
     */
168
    protected function githubCommitRequest(Project $project, array $payload)
169
    {
170
        // Github sends a payload when you close a pull request with a
171
        // non-existent commit. We don't want this.
172
        if (array_key_exists('after', $payload) && $payload['after'] === '0000000000000000000000000000000000000000') {
173
            return array('status' => 'ignored');
174
        }
175
176
        if (isset($payload['commits']) && is_array($payload['commits'])) {
177
            // If we have a list of commits, then add them all as builds to be tested:
178
179
            $results = array();
180
            $status = 'failed';
181
            foreach ($payload['commits'] as $commit) {
182
                if (!$commit['distinct']) {
183
                    $results[$commit['id']] = array('status' => 'ignored');
184
                    continue;
185
                }
186
187
                try {
188
                    $branch = str_replace('refs/heads/', '', $payload['ref']);
189
                    $committer = $commit['committer']['email'];
190
                    $results[$commit['id']] = $this->createBuild(
191
                        $project,
192
                        $commit['id'],
193
                        $branch,
194
                        $committer,
195
                        $commit['message']
196
                    );
197
                    $status = 'ok';
198
                } catch (Exception $ex) {
199
                    $results[$commit['id']] = array('status' => 'failed', 'error' => $ex->getMessage());
200
                }
201
            }
202
203
            return array('status' => $status, 'commits' => $results);
204
        }
205
206
        if (substr($payload['ref'], 0, 10) == 'refs/tags/') {
207
            // If we don't, but we're dealing with a tag, add that instead:
208
            $branch = str_replace('refs/tags/', 'Tag: ', $payload['ref']);
209
            $committer = $payload['pusher']['email'];
210
            $message = $payload['head_commit']['message'];
211
212
            return $this->createBuild($project, $payload['after'], $branch, $committer, $message);
213
        }
214
215
        return array('status' => 'ignored', 'message' => 'Unusable payload.');
216
    }
217
218
    /**
219
     * Handle the payload when Github sends a Pull Request webhook.
220
     *
221
     * @param Project $project
222
     * @param array   $payload
223
     */
224
    protected function githubPullRequest(Project $project, array $payload)
225
    {
226
        // We only want to know about open pull requests:
227
        if (!in_array($payload['action'], array('opened', 'synchronize', 'reopened'))) {
228
            return array('status' => 'ok');
229
        }
230
231
        $headers = array();
232
        $token = \b8\Config::getInstance()->get('phpci.github.token');
233
234
        if (!empty($token)) {
235
            $headers[] = 'Authorization: token '.$token;
236
        }
237
238
        $url = $payload['pull_request']['commits_url'];
239
        $http = new \b8\HttpClient();
240
        $http->setHeaders($headers);
241
        $response = $http->get($url);
242
243
        // Check we got a success response:
244
        if (!$response['success']) {
245
            throw new Exception('Could not get commits, failed API request.');
246
        }
247
248
        $results = array();
249
        $status = 'failed';
250
        foreach ($response['body'] as $commit) {
251
            // Skip all but the current HEAD commit ID:
252
            $id = $commit['sha'];
253
            if ($id != $payload['pull_request']['head']['sha']) {
254
                $results[$id] = array('status' => 'ignored', 'message' => 'not branch head');
255
                continue;
256
            }
257
258
            try {
259
                $branch = str_replace('refs/heads/', '', $payload['pull_request']['base']['ref']);
260
                $committer = $commit['commit']['author']['email'];
261
                $message = $commit['commit']['message'];
262
263
                $remoteUrlKey = $payload['pull_request']['head']['repo']['private'] ? 'ssh_url' : 'clone_url';
264
265
                $extra = array(
266
                    'build_type' => 'pull_request',
267
                    'pull_request_id' => $payload['pull_request']['id'],
268
                    'pull_request_number' => $payload['number'],
269
                    'remote_branch' => $payload['pull_request']['head']['ref'],
270
                    'remote_url' => $payload['pull_request']['head']['repo'][$remoteUrlKey],
271
                );
272
273
                $results[$id] = $this->createBuild($project, $id, $branch, $committer, $message, $extra);
274
                $status = 'ok';
275
            } catch (Exception $ex) {
276
                $results[$id] = array('status' => 'failed', 'error' => $ex->getMessage());
277
            }
278
        }
279
280
        return array('status' => $status, 'commits' => $results);
281
    }
282
283
    /**
284
     * Called by Gitlab Webhooks:.
285
     */
286
    public function gitlab($projectId)
287
    {
288
        $project = $this->fetchProject($projectId, 'gitlab');
289
290
        $payloadString = file_get_contents('php://input');
291
        $payload = json_decode($payloadString, true);
292
293
        // build on merge request events
294
        if (isset($payload['object_kind']) && $payload['object_kind'] == 'merge_request') {
295
            $attributes = $payload['object_attributes'];
296
            if ($attributes['state'] == 'opened' || $attributes['state'] == 'reopened') {
297
                $branch = $attributes['source_branch'];
298
                $commit = $attributes['last_commit'];
299
                $committer = $commit['author']['email'];
300
301
                return $this->createBuild($project, $commit['id'], $branch, $committer, $commit['message']);
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($projectId, 'gitlab') on line 288 can be null; however, PHPCI\Controller\WebhookController::createBuild() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
302
            }
303
        }
304
305
        // build on push events
306
        if (isset($payload['commits']) && is_array($payload['commits'])) {
307
            // If we have a list of commits, then add them all as builds to be tested:
308
309
            $results = array();
310
            $status = 'failed';
311
            foreach ($payload['commits'] as $commit) {
312
                try {
313
                    $branch = str_replace('refs/heads/', '', $payload['ref']);
314
                    $committer = $commit['author']['email'];
315
                    $results[$commit['id']] = $this->createBuild(
316
                        $project,
0 ignored issues
show
Bug introduced by
It seems like $project defined by $this->fetchProject($projectId, 'gitlab') on line 288 can be null; however, PHPCI\Controller\WebhookController::createBuild() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
317
                        $commit['id'],
318
                        $branch,
319
                        $committer,
320
                        $commit['message']
321
                    );
322
                    $status = 'ok';
323
                } catch (Exception $ex) {
324
                    $results[$commit['id']] = array('status' => 'failed', 'error' => $ex->getMessage());
325
                }
326
            }
327
328
            return array('status' => $status, 'commits' => $results);
329
        }
330
331
        return array('status' => 'ignored', 'message' => 'Unusable payload.');
332
    }
333
334
    /**
335
     * Wrapper for creating a new build.
336
     *
337
     * @param Project $project
338
     * @param string  $commitId
339
     * @param string  $branch
340
     * @param string  $committer
341
     * @param string  $commitMessage
342
     * @param array   $extra
343
     *
344
     * @return array
345
     *
346
     * @throws Exception
347
     */
348
    protected function createBuild(
349
        Project $project,
350
        $commitId,
351
        $branch,
352
        $committer,
353
        $commitMessage,
354
        array $extra = null
355
    ) {
356
        // Check if a build already exists for this commit ID:
357
        $builds = $this->buildStore->getByProjectAndCommit($project->getId(), $commitId);
358
359
        if ($builds['count']) {
360
            return array(
361
                'status' => 'ignored',
362
                'message' => sprintf('Duplicate of build #%d', $builds['items'][0]->getId()),
363
            );
364
        }
365
366
        // If not, create a new build job for it:
367
        $build = $this->buildService->createBuild($project, $commitId, $branch, $committer, $commitMessage, $extra);
0 ignored issues
show
Bug introduced by
It seems like $extra defined by parameter $extra on line 354 can also be of type array; however, PHPCI\Service\BuildService::createBuild() does only seem to accept string|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
368
369
        return array('status' => 'ok', 'buildID' => $build->getID());
370
    }
371
372
    /**
373
     * Fetch a project and check its type.
374
     *
375
     * @param int          $projectId
376
     * @param array|string $expectedType
377
     *
378
     * @return Project
379
     *
380
     * @throws Exception If the project does not exist or is not of the expected type.
381
     */
382
    protected function fetchProject($projectId, $expectedType)
383
    {
384
        $project = $this->projectStore->getById($projectId);
385
386
        if (empty($projectId)) {
387
            throw new Exception('Project does not exist: '.$projectId);
388
        }
389
390
        if (is_array($expectedType)
391
            ? !in_array($project->getType(), $expectedType)
392
            : $project->getType() !== $expectedType
393
        ) {
394
            throw new Exception('Wrong project type: '.$project->getType());
395
        }
396
397
        return $project;
398
    }
399
}
400