Passed
Push — main ( 394fb3...82ed03 )
by Sebastian
03:26
created

Hook::afterAction()   A

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