Hook::beforeHook()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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