Issues (48)

src/Runner/Hook.php (2 issues)

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