Hook::checkHookScript()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * This file is part of CaptainHook
5
 *
6
 * (c) Sebastian Feldmann <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace CaptainHook\App\Runner;
13
14
use CaptainHook\App\Config;
15
use CaptainHook\App\Console\IO;
16
use CaptainHook\App\Event\Dispatcher;
17
use CaptainHook\App\Exception\ActionFailed;
18
use CaptainHook\App\Exception\ActionNotApplicable;
19
use CaptainHook\App\Hook\Constrained;
20
use CaptainHook\App\Hook\Template\Inspector;
21
use CaptainHook\App\Plugin;
22
use CaptainHook\App\Runner\Action\Log as ActionLog;
23
use CaptainHook\App\Runner\Hook\Log as HookLog;
24
use CaptainHook\App\Runner\Hook\Printer;
25
use Exception;
26
use SebastianFeldmann\Git\Repository;
27
28
/**
29
 * Hook
30
 *
31
 * @package CaptainHook
32
 * @author  Sebastian Feldmann <[email protected]>
33
 * @link    https://github.com/captainhook-git/captainhook
34
 * @since   Class available since Release 0.9.0
35
 */
36
abstract class Hook extends RepositoryAware
37
{
38
    /**
39
     * Hook status constants
40
     */
41
    public const HOOK_SUCCEEDED = 0;
42
    public const HOOK_FAILED    = 1;
43
44
    /**
45
     * Hook that should be handled
46
     *
47
     * @var string
48
     */
49
    protected string $hook;
50
51
    /**
52
     * Set to `true` to skip processing this hook's actions
53
     *
54
     * @var bool
55
     */
56
    private bool $skipActions = false;
57
58
    /**
59
     * Event dispatcher
60
     *
61
     * @var \CaptainHook\App\Event\Dispatcher
62
     */
63
    protected Dispatcher $dispatcher;
64
65
    /**
66
     * Plugins to apply to this hook
67
     *
68
     * @var array<Plugin\Hook>|null
69
     */
70
    private ?array $hookPlugins = null;
71
72
    /**
73
     * Handling the hook output
74
     *
75
     * @var \CaptainHook\App\Runner\Hook\Printer
76
     */
77
    private Printer $printer;
78
79
    /**
80
     * Logs all output to do it at the end
81
     *
82
     * @var \CaptainHook\App\Runner\Hook\Log
83
     */
84
    private HookLog $hookLog;
85
86
    /**
87
     * Should the plugins be disabled?
88
     *
89
     * @var boolean
90
     */
91
    private bool $pluginsDisabled = false;
92
93
    /**
94
     * Set up the hook runner
95
     *
96
     * @param \CaptainHook\App\Console\IO       $io
97
     * @param \CaptainHook\App\Config           $config
98
     * @param \SebastianFeldmann\Git\Repository $repository
99
     */
100 47
    public function __construct(IO $io, Config $config, Repository $repository)
101
    {
102 47
        $this->dispatcher = new Dispatcher($io, $config, $repository);
103 47
        $this->printer    = new Printer($io);
104 47
        $this->hookLog    = new HookLog();
105
106 47
        parent::__construct($io, $config, $repository);
107
    }
108
109
    /**
110
     * Allow plugins to be disabled
111
     *
112
     * @param  bool $disabled
113
     * @return void
114
     */
115 12
    public function setPluginsDisabled(bool $disabled): void
116
    {
117 12
        $this->pluginsDisabled = $disabled;
118
    }
119
120
    /**
121
     * Return this hook's name
122
     *
123
     * @return string
124
     */
125 6
    public function getName(): string
126
    {
127 6
        return $this->hook;
128
    }
129
130
    /**
131
     * Execute stuff before executing any actions
132
     *
133
     * @return void
134
     */
135 27
    public function beforeHook(): void
136
    {
137 27
        $this->executeHookPluginsFor('beforeHook');
138
    }
139
140
    /**
141
     * Execute stuff before every action
142
     *
143
     * @param Config\Action $action
144
     * @return void
145
     */
146 19
    public function beforeAction(Config\Action $action): void
147
    {
148 19
        $this->executeHookPluginsFor('beforeAction', $action);
149
    }
150
151
    /**
152
     * Execute stuff after every action
153
     *
154
     * @param Config\Action $action
155
     * @return void
156
     */
157 20
    public function afterAction(Config\Action $action): void
158
    {
159 20
        $this->executeHookPluginsFor('afterAction', $action);
160
    }
161
162
    /**
163
     * Execute stuff after all actions
164
     *
165
     * @return void
166
     */
167 27
    public function afterHook(): void
168
    {
169 27
        $this->executeHookPluginsFor('afterHook');
170
    }
171
172
    /**
173
     * Execute the hook and all its actions
174
     *
175
     * @return void
176
     * @throws \Exception
177
     */
178 38
    public function run(): void
179
    {
180 38
        $this->io->write('<comment>' . $this->hook . ':</comment> ');
181
182 38
        if (!$this->config->isHookEnabled($this->hook)) {
183 9
            $this->io->write(' - hook is disabled');
184 9
            return;
185
        }
186
187 29
        if ($this->pluginsDisabled) {
188 1
            $this->io->write('<fg=magenta>Running with plugins disabled</>');
189
        }
190
191 29
        $this->checkHookScript();
192
193 29
        $this->beforeHook();
194
        try {
195 27
            $this->runHook();
196
        } finally {
197 27
            $this->afterHook();
198
        }
199
    }
200
201
    /**
202
     * @return void
203
     * @throws \Exception
204
     */
205 26
    protected function runHook(): void
206
    {
207 26
        $hookConfig = $this->config->getHookConfigToExecute($this->hook);
208 26
        $actions    = $hookConfig->getActions();
209
        // are any actions configured?
210 26
        if (count($actions) === 0) {
211 5
            $this->io->write(' - no actions to execute');
212
        } else {
213 21
            $this->executeActions($actions);
214
        }
215
    }
216
217
    /**
218
     * Returns `true` if something has indicated that the hook should skip all
219
     * remaining actions; pass a boolean value to toggle this
220
     *
221
     * There may be times you want to conditionally skip all actions, based on
222
     * logic in {@see beforeHook()}. Other times, you may wish to skip the rest
223
     * of the actions based on some condition of the current action.
224
     *
225
     * - To skip all actions for a hook, set this to `true`
226
     *   in {@see beforeHook()}.
227
     * - To skip the current action and all remaining actions, set this
228
     *   to `true` in {@see beforeAction()}.
229
     * - To run the current action but skip all remaining actions, set this
230
     *   to `true` in {@see afterAction()}.
231
     *
232
     * @param bool|null $shouldSkip
233
     * @return bool
234
     */
235 25
    public function shouldSkipActions(?bool $shouldSkip = null): bool
236
    {
237 25
        if ($shouldSkip !== null) {
238 5
            $this->skipActions = $shouldSkip;
239
        }
240 25
        return $this->skipActions;
241
    }
242
243
    /**
244
     * Executes all the Actions configured for the hook
245
     *
246
     * @param  \CaptainHook\App\Config\Action[] $actions
247
     * @throws \Exception
248
     */
249 21
    private function executeActions(array $actions): void
250
    {
251 21
        $status = self::HOOK_SUCCEEDED;
252 21
        $start  = microtime(true);
253
        try {
254 21
            if ($this->config->failOnFirstError()) {
255 8
                $this->executeFailOnFirstError($actions);
256
            } else {
257 13
                $this->executeFailAfterAllActions($actions);
258
            }
259 17
            $this->dispatcher->dispatch('onHookSuccess');
260 4
        } catch (Exception $e) {
261 4
            $status = self::HOOK_FAILED;
262 4
            $this->dispatcher->dispatch('onHookFailure');
263 4
            throw $e;
264
        } finally {
265 21
            $duration = microtime(true) - $start;
266 21
            $this->printer->hookEnded($status, $this->hookLog, $duration);
267
        }
268
    }
269
270
    /**
271
     * Executes all actions and fails at the first error
272
     *
273
     * @param  \CaptainHook\App\Config\Action[] $actions
274
     * @return void
275
     * @throws \Exception
276
     */
277 8
    private function executeFailOnFirstError(array $actions): void
278
    {
279 8
        foreach ($actions as $action) {
280 8
            $this->handleAction($action);
281
        }
282
    }
283
284
    /**
285
     * Executes all actions but does not fail immediately
286
     *
287
     * @param \CaptainHook\App\Config\Action[] $actions
288
     * @return void
289
     * @throws \CaptainHook\App\Exception\ActionFailed
290
     */
291 13
    private function executeFailAfterAllActions(array $actions): void
292
    {
293 13
        $failedActions = 0;
294 13
        foreach ($actions as $action) {
295
            try {
296 13
                $this->handleAction($action);
297 1
            } catch (Exception $exception) {
298 1
                $failedActions++;
299
            }
300
        }
301 13
        if ($failedActions > 0) {
302 1
            throw new ActionFailed($failedActions . ' action' . ($failedActions > 1 ? 's' : '') . ' failed');
303
        }
304
    }
305
306
    /**
307
     * Executes a configured hook action
308
     *
309
     * @param  \CaptainHook\App\Config\Action $action
310
     * @return void
311
     * @throws \Exception
312
     */
313 21
    private function handleAction(Config\Action $action): void
314
    {
315 21
        if ($this->shouldSkipActions()) {
316 2
            $this->printer->actionDeactivated($action);
317 2
            return;
318
        }
319
320 20
        $io     = new IO\CollectorIO($this->io);
321 20
        $status = ActionLog::ACTION_SUCCEEDED;
322
323
        try {
324 20
            if (!$this->doConditionsApply($action->getConditions(), $io)) {
325 1
                $this->printer->actionSkipped($action);
326 1
                return;
327
            }
328
329 19
            $this->beforeAction($action);
330
331
            // The beforeAction() method may indicate that the current and all
332
            // remaining actions should be skipped. If so, return here.
333 19
            if ($this->shouldSkipActions()) {
334 1
                return;
335
            }
336
337 19
            $runner = $this->createActionRunner(Util::getExecType($action->getAction()));
338 19
            $runner->execute($this->config, $io, $this->repository, $action);
339 14
            $this->printer->actionSucceeded($action);
340 7
        } catch (ActionNotApplicable $e) {
341 1
            $this->printer->actionSkipped($action);
342 1
            return;
343 6
        } catch (Exception $e) {
344 6
            $status = ActionLog::ACTION_FAILED;
345 6
            $this->printer->actionFailed($action);
346 6
            if (!$action->isFailureAllowed($this->config->isFailureAllowed())) {
347 4
                throw $e;
348
            }
349 2
            $io->write('<fg=yellow>' . $e->getMessage() . '</>');
350
        } finally {
351 20
            $this->hookLog->addActionLog(new ActionLog($action, $status, $io->getMessages()));
352 20
            $this->afterAction($action);
353
        }
354
    }
355
356
    /**
357
     * Return the right method name to execute an action
358
     *
359
     * @param  string $type
360
     * @return \CaptainHook\App\Runner\Action
361
     */
362 19
    private function createActionRunner(string $type): Action
363
    {
364 19
        $valid = [
365 19
            'php' => fn(): Action => new Action\PHP($this->hook, $this->dispatcher),
366 19
            'cli' => fn(): Action => new Action\Cli(),
367 19
        ];
368 19
        return $valid[$type]();
369
    }
370
371
    /**
372
     * Check if conditions apply
373
     *
374
     * @param \CaptainHook\App\Config\Condition[] $conditions
375
     * @param \CaptainHook\App\Console\IO         $collectorIO
376
     * @return bool
377
     */
378 20
    private function doConditionsApply(array $conditions, IO $collectorIO): bool
379
    {
380 20
        $conditionRunner = new Condition($collectorIO, $this->repository, $this->config, $this->hook);
381 20
        foreach ($conditions as $config) {
382 2
            $collectorIO->write('  <fg=cyan>Condition: ' . $config->getExec() . '</>', true, IO::VERBOSE);
383 2
            if (!$conditionRunner->doesConditionApply($config)) {
384 1
                return false;
385
            }
386
        }
387 19
        return true;
388
    }
389
390
    /**
391
     * Return plugins to apply to this hook.
392
     *
393
     * @return array<Plugin\Hook>
394
     */
395 26
    private function getHookPlugins(): array
396
    {
397 26
        if ($this->hookPlugins == null) {
398 26
            $this->hookPlugins = $this->setupHookPlugins();
399
        }
400 26
        return $this->hookPlugins;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->hookPlugins could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
401
    }
402
403
    /**
404
     *
405
     * Setup configured hook plugins
406
     *
407
     * @return array<Plugin\Hook>
408
     */
409 26
    private function setupHookPlugins(): array
410
    {
411 26
        $this->hookPlugins = [];
412
413 26
        foreach ($this->config->getPlugins() as $pluginConfig) {
414 3
            $pluginClass = $pluginConfig->getPlugin();
415 3
            if (!is_a($pluginClass, Plugin\Hook::class, true)) {
416 1
                continue;
417
            }
418
419 3
            $this->io->write(
420 3
                ['', 'Configuring Hook Plugin: <comment>' . $pluginClass . '</comment>'],
421 3
                true,
422 3
                IO::VERBOSE
423 3
            );
424
425 3
            if ($this->isPluginApplicableForCurrentHook($pluginClass)) {
426 1
                $this->io->write('Skipped because plugin is not applicable for hook ' . $this->hook, true, IO::VERBOSE);
427 1
                continue;
428
            }
429
430 3
            $plugin = new $pluginClass();
431 3
            $plugin->configure($this->config, $this->io, $this->repository, $pluginConfig);
432
433 3
            $this->hookPlugins[] = $plugin;
434
        }
435 26
        return $this->hookPlugins;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->hookPlugins could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
436
    }
437
438
    /**
439
     * Execute hook plugins for the given method name (i.e., beforeHook,
440
     * beforeAction, afterAction, afterHook).
441
     *
442
     * @param string $method
443
     * @param Config\Action|null $action
444
     * @return void
445
     */
446 27
    private function executeHookPluginsFor(string $method, ?Config\Action $action = null): void
447
    {
448 27
        if ($this->pluginsDisabled) {
449 1
            return;
450
        }
451
452 26
        $plugins = $this->getHookPlugins();
453
454 26
        if (count($plugins) === 0) {
455 23
            $this->io->write(['No plugins to execute for: <comment>' . $method . '</comment>'], true, IO::DEBUG);
456 23
            return;
457
        }
458
459 3
        $params = [$this];
460
461 3
        if ($action !== null) {
462 2
            $params[] = $action;
463
        }
464
465 3
        $this->io->write(['Executing plugins for: <comment>' . $method . '</comment>'], true, IO::DEBUG);
466
467 3
        foreach ($plugins as $plugin) {
468 3
            $this->io->write('<info>- Running ' . get_class($plugin) . '::' . $method . '</info>', true, IO::DEBUG);
469 3
            $plugin->{$method}(...$params);
470
        }
471
    }
472
473
    /**
474
     * Makes sure the hook script was installed/created with a decent enough version
475
     *
476
     * @return void
477
     * @throws \Exception
478
     */
479 29
    private function checkHookScript(): void
480
    {
481 29
        $inspector = new Inspector($this->hook, $this->io, $this->repository);
482 29
        $inspector->inspect();
483
    }
484
485
    /**
486
     * @param  string $pluginClass
487
     * @return bool
488
     */
489 3
    private function isPluginApplicableForCurrentHook(string $pluginClass): bool
490
    {
491 3
        return is_a($pluginClass, Constrained::class, true)
492 3
               && !$pluginClass::getRestriction()->isApplicableFor($this->hook);
493
    }
494
}
495