Passed
Push — main ( 3e0915...03267e )
by Sebastian
03:28
created

Hook::isPluginApplicableForCurrentHook()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 2
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 2
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\Hook\Constrained;
19
use CaptainHook\App\Hook\Template\Inspector;
20
use CaptainHook\App\Hooks;
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/captainhookphp/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 $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 45
    public function __construct(IO $io, Config $config, Repository $repository)
87
    {
88 45
        $this->dispatcher = new Dispatcher($io, $config, $repository);
89 45
        $this->printer    = new Printer($io);
90 45
        $this->hookLog    = new HookLog();
91
92 45
        parent::__construct($io, $config, $repository);
93
    }
94
95
    /**
96
     * Return this hook's name
97
     *
98
     * @return string
99
     */
100 6
    public function getName(): string
101
    {
102 6
        return $this->hook;
103
    }
104
105
    /**
106
     * Execute stuff before executing any actions
107
     *
108
     * @return void
109
     */
110 25
    public function beforeHook(): void
111
    {
112 25
        $this->executeHookPluginsFor('beforeHook');
113
    }
114
115
    /**
116
     * Execute stuff before every actions
117
     *
118
     * @param Config\Action $action
119
     * @return void
120
     */
121 17
    public function beforeAction(Config\Action $action): void
122
    {
123 17
        $this->executeHookPluginsFor('beforeAction', $action);
124
    }
125
126
    /**
127
     * Execute stuff after every actions
128
     *
129
     * @param Config\Action $action
130
     * @return void
131
     */
132 18
    public function afterAction(Config\Action $action): void
133
    {
134 18
        $this->executeHookPluginsFor('afterAction', $action);
135
    }
136
137
    /**
138
     * Execute stuff after all actions
139
     *
140
     * @return void
141
     */
142 25
    public function afterHook(): void
143
    {
144 25
        $this->executeHookPluginsFor('afterHook');
145
    }
146
147
    /**
148
     * Execute the hook and all its actions
149
     *
150
     * @return void
151
     * @throws \Exception
152
     */
153 36
    public function run(): void
154
    {
155 36
        $this->io->write('<comment>' . $this->hook . ':</comment> ');
156
157 36
        if (!$this->config->isHookEnabled($this->hook)) {
158 9
            $this->io->write(' - hook is disabled');
159 9
            return;
160
        }
161
162 27
        $this->checkHookScript();
163
164 27
        $this->beforeHook();
165
        try {
166 25
            $this->runHook();
167
        } finally {
168 25
            $this->afterHook();
169
        }
170
    }
171
172
    /**
173
     * @return void
174
     * @throws \Exception
175
     */
176 24
    protected function runHook(): void
177
    {
178 24
        $hookConfig = $this->config->getHookConfigToExecute($this->hook);
179 24
        $actions    = $hookConfig->getActions();
180
        // are any actions configured
181 24
        if (count($actions) === 0) {
182 5
            $this->io->write(' - no actions to execute');
183
        } else {
184 19
            $this->executeActions($actions);
185
        }
186
    }
187
188
    /**
189
     * Returns `true` if something has indicated that the hook should skip all
190
     * remaining actions; pass a boolean value to toggle this
191
     *
192
     * There may be times you want to conditionally skip all actions, based on
193
     * logic in {@see beforeHook()}. Other times, you may wish to skip the rest
194
     * of the actions based on some condition of the current action.
195
     *
196
     * - To skip all actions for a hook, set this to `true`
197
     *   in {@see beforeHook()}.
198
     * - To skip the current action and all remaining actions, set this
199
     *   to `true` in {@see beforeAction()}.
200
     * - To run the current action but skip all remaining actions, set this
201
     *   to `true` in {@see afterAction()}.
202
     *
203
     * @param bool|null $shouldSkip
204
     * @return bool
205
     */
206 23
    public function shouldSkipActions(?bool $shouldSkip = null): bool
207
    {
208 23
        if ($shouldSkip !== null) {
209 5
            $this->skipActions = $shouldSkip;
210
        }
211 23
        return $this->skipActions;
212
    }
213
214
    /**
215
     * Executes all the Actions configured for the hook
216
     *
217
     * @param  \CaptainHook\App\Config\Action[] $actions
218
     * @throws \Exception
219
     */
220 19
    private function executeActions(array $actions): void
221
    {
222 19
        $status = self::HOOK_SUCCEEDED;
223 19
        $start  = microtime(true);
224
        try {
225 19
            if ($this->config->failOnFirstError()) {
226 7
                $this->executeFailOnFirstError($actions);
227
            } else {
228 12
                $this->executeFailAfterAllActions($actions);
229
            }
230 15
            $this->dispatcher->dispatch('onHookSuccess');
231 4
        } catch (Exception $e) {
232 4
            $status = self::HOOK_FAILED;
233 4
            $this->dispatcher->dispatch('onHookFailure');
234 4
            throw $e;
235
        } finally {
236 19
            $duration = microtime(true) - $start;
237 19
            $this->printer->hookEnded($status, $this->hookLog, $duration);
238
        }
239
    }
240
241
    /**
242
     * Executes all actions and fails at the first error
243
     *
244
     * @param  \CaptainHook\App\Config\Action[] $actions
245
     * @return void
246
     * @throws \Exception
247
     */
248 7
    private function executeFailOnFirstError(array $actions): void
249
    {
250 7
        foreach ($actions as $action) {
251 7
            $this->handleAction($action);
252
        }
253
    }
254
255
    /**
256
     * Executes all actions but does not fail immediately
257
     *
258
     * @param \CaptainHook\App\Config\Action[] $actions
259
     * @return void
260
     * @throws \CaptainHook\App\Exception\ActionFailed
261
     */
262 12
    private function executeFailAfterAllActions(array $actions): void
263
    {
264 12
        $failedActions = 0;
265 12
        foreach ($actions as $action) {
266
            try {
267 12
                $this->handleAction($action);
268 1
            } catch (Exception $exception) {
269 1
                $failedActions++;
270
            }
271
        }
272 12
        if ($failedActions > 0) {
273 1
            throw new ActionFailed($failedActions . ' action' . ($failedActions > 1 ? 's' : '') . ' failed');
274
        }
275
    }
276
277
    /**
278
     * Executes a configured hook action
279
     *
280
     * @param  \CaptainHook\App\Config\Action $action
281
     * @return void
282
     * @throws \Exception
283
     */
284 19
    private function handleAction(Config\Action $action): void
285
    {
286 19
        if ($this->shouldSkipActions()) {
287 2
            $this->printer->actionDeactivated($action);
288 2
            return;
289
        }
290
291 18
        $io     = new IO\CollectorIO($this->io);
292 18
        $status = ActionLog::ACTION_SUCCEEDED;
293
294
        try {
295 18
            if (!$this->doConditionsApply($action->getConditions(), $io)) {
296 1
                $this->printer->actionSkipped($action);
297 1
                return;
298
            }
299
300 17
            $this->beforeAction($action);
301
302
            // The beforeAction() method may indicate that the current and all
303
            // remaining actions should be skipped. If so, return here.
304 17
            if ($this->shouldSkipActions()) {
305 1
                return;
306
            }
307
308 17
            $runner = $this->createActionRunner(Util::getExecType($action->getAction()));
309 17
            $runner->execute($this->config, $io, $this->repository, $action);
310 13
            $this->printer->actionSucceeded($action);
311 6
        } catch (Exception  $e) {
312 6
            $status = ActionLog::ACTION_FAILED;
313 6
            $this->printer->actionFailed($action);
314 6
            $io->write('<fg=yellow>' . $e->getMessage() . '</>');
315 6
            if (!$action->isFailureAllowed($this->config->isFailureAllowed())) {
316 4
                throw $e;
317
            }
318
        } finally {
319 18
            $this->hookLog->addActionLog(new ActionLog($action, $status, $io->getMessages()));
320 18
            $this->afterAction($action);
321
        }
322
    }
323
324
    /**
325
     * Return the right method name to execute an action
326
     *
327
     * @param  string $type
328
     * @return \CaptainHook\App\Runner\Action
329
     */
330 17
    private function createActionRunner(string $type): Action
331
    {
332 17
        $valid = [
333 17
            'php' => fn(): Action => new Action\PHP($this->hook, $this->dispatcher),
334 17
            'cli' => fn(): Action => new Action\Cli(),
335 17
        ];
336 17
        return $valid[$type]();
337
    }
338
339
    /**
340
     * Check if conditions apply
341
     *
342
     * @param \CaptainHook\App\Config\Condition[] $conditions
343
     * @param \CaptainHook\App\Console\IO         $collectorIO
344
     * @return bool
345
     */
346 18
    private function doConditionsApply(array $conditions, IO $collectorIO): bool
347
    {
348 18
        $conditionRunner = new Condition($collectorIO, $this->repository, $this->config, $this->hook);
349 18
        foreach ($conditions as $config) {
350 2
            if (!$conditionRunner->doesConditionApply($config)) {
351 1
                return false;
352
            }
353
        }
354 17
        return true;
355
    }
356
357
    /**
358
     * Return plugins to apply to this hook.
359
     *
360
     * @return array<Plugin\Hook>
361
     */
362 25
    private function getHookPlugins(): array
363
    {
364 25
        if ($this->hookPlugins == null) {
365 25
            $this->hookPlugins = $this->setupHookPlugins();
366
        }
367 25
        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...
368
    }
369
370
    /**
371
     *
372
     * Setup configured hook plugins
373
     *
374
     * @return array<Plugin\Hook>
375
     */
376 25
    private function setupHookPlugins(): array
377
    {
378 25
        $this->hookPlugins = [];
379
380 25
        foreach ($this->config->getPlugins() as $pluginConfig) {
381 3
            $pluginClass = $pluginConfig->getPlugin();
382 3
            if (!is_a($pluginClass, Plugin\Hook::class, true)) {
383 1
                continue;
384
            }
385
386 3
            $this->io->write(
387 3
                ['', 'Configuring Hook Plugin: <comment>' . $pluginClass . '</comment>'],
388 3
                true,
389 3
                IO::VERBOSE
390 3
            );
391
392 3
            if ($this->isPluginApplicableForCurrentHook($pluginClass)) {
393 1
                $this->io->write('Skipped because plugin is not applicable for hook ' . $this->hook, true, IO::VERBOSE);
394 1
                continue;
395
            }
396
397 3
            $plugin = new $pluginClass();
398 3
            $plugin->configure($this->config, $this->io, $this->repository, $pluginConfig);
399
400 3
            $this->hookPlugins[] = $plugin;
401
        }
402 25
        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...
403
    }
404
405
    /**
406
     * Execute hook plugins for the given method name (i.e., beforeHook,
407
     * beforeAction, afterAction, afterHook).
408
     *
409
     * @param string $method
410
     * @param Config\Action|null $action
411
     * @return void
412
     */
413 25
    private function executeHookPluginsFor(string $method, ?Config\Action $action = null): void
414
    {
415 25
        $plugins = $this->getHookPlugins();
416
417 25
        if (count($plugins) === 0) {
418 22
            $this->io->write(['No plugins to execute for: <comment>' . $method . '</comment>'], true, IO::DEBUG);
419 22
            return;
420
        }
421
422 3
        $params = [$this];
423
424 3
        if ($action !== null) {
425 2
            $params[] = $action;
426
        }
427
428 3
        $this->io->write(['Executing plugins for: <comment>' . $method . '</comment>'], true, IO::DEBUG);
429
430 3
        foreach ($plugins as $plugin) {
431 3
            $this->io->write('<info>- Running ' . get_class($plugin) . '::' . $method . '</info>', true, IO::DEBUG);
432 3
            $plugin->{$method}(...$params);
433
        }
434
    }
435
436
    /**
437
     * Makes sure the hook script was installed/created with a decent enough version
438
     *
439
     * @return void
440
     * @throws \Exception
441
     */
442 27
    private function checkHookScript(): void
443
    {
444 27
        $inspector = new Inspector($this->hook, $this->io, $this->repository);
445 27
        $inspector->inspect();
446
    }
447
448
    /**
449
     * @param  string $pluginClass
450
     * @return bool
451
     */
452 3
    private function isPluginApplicableForCurrentHook(string $pluginClass): bool
453
    {
454 3
        return is_a($pluginClass, Constrained::class, true)
455 3
               && !$pluginClass::getRestriction()->isApplicableFor($this->hook);
456
    }
457
}
458