Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
GithubActionOutput
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 14
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __destruct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 error
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 warning
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 notice
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 startGroup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 endGroup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 group
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 emit
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 supportsWorkflowCommands
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isTruthyEnvironmentFlag
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 escapeData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 escapeProperty
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Output;
21
22use FastForward\DevTools\Environment\EnvironmentInterface;
23use Symfony\Component\Console\Output\ConsoleOutputInterface;
24
25/**
26 * Emits GitHub Actions workflow commands through the console output stream.
27 *
28 * The helper becomes a no-op outside GitHub Actions, which lets callers route
29 * annotations and log groups through a single API without branching on the
30 * environment first.
31 */
32 class GithubActionOutput
33{
34    private ?string $currentGroup = null;
35
36    /**
37     * @param ConsoleOutputInterface $output the console output used to emit workflow commands
38     * @param EnvironmentInterface $environment reads runtime environment flags
39     */
40    public function __construct(
41        private  ConsoleOutputInterface $output,
42        private  EnvironmentInterface $environment,
43    ) {}
44
45    /**
46     * Closes any open group before the output instance is destroyed.
47     */
48    public function __destruct()
49    {
50        if (null !== $this->currentGroup) {
51            $this->endGroup();
52        }
53    }
54
55    /**
56     * Emits an error annotation for the current workflow step.
57     *
58     * @param string $message the annotation message
59     * @param string|null $file the related file when known
60     * @param int|null $line the related line when known
61     * @param int|null $column the related column when known
62     *
63     * @return void
64     */
65    public function error(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
66    {
67        $properties = [];
68
69        if (null !== $file) {
70            $properties['file'] = $file;
71        }
72
73        if (null !== $line) {
74            $properties['line'] = (string) $line;
75        }
76
77        if (null !== $column) {
78            $properties['col'] = (string) $column;
79        }
80
81        $this->emit('error', $message, $properties);
82    }
83
84    /**
85     * Emits a warning annotation for the current workflow step.
86     *
87     * @param string $message the annotation message
88     * @param string|null $file the related file when known
89     * @param int|null $line the related line when known
90     * @param int|null $column the related column when known
91     *
92     * @return void
93     */
94    public function warning(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
95    {
96        $properties = [];
97
98        if (null !== $file) {
99            $properties['file'] = $file;
100        }
101
102        if (null !== $line) {
103            $properties['line'] = (string) $line;
104        }
105
106        if (null !== $column) {
107            $properties['col'] = (string) $column;
108        }
109
110        $this->emit('warning', $message, $properties);
111    }
112
113    /**
114     * Emits a notice annotation for the current workflow step.
115     *
116     * @param string $message the annotation message
117     * @param string|null $file the related file when known
118     * @param int|null $line the related line when known
119     * @param int|null $column the related column when known
120     *
121     * @return void
122     */
123    public function notice(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
124    {
125        $properties = [];
126
127        if (null !== $file) {
128            $properties['file'] = $file;
129        }
130
131        if (null !== $line) {
132            $properties['line'] = (string) $line;
133        }
134
135        if (null !== $column) {
136            $properties['col'] = (string) $column;
137        }
138
139        $this->emit('notice', $message, $properties);
140    }
141
142    /**
143     * Emits a debug workflow command.
144     *
145     * @param string $message the debug message
146     *
147     * @return void
148     */
149    public function debug(string $message): void
150    {
151        $this->emit('debug', $message);
152    }
153
154    /**
155     * Starts a collapsible GitHub Actions log group.
156     *
157     * @param string $title the group title
158     *
159     * @return void
160     */
161    public function startGroup(string $title): void
162    {
163        if (null !== $this->currentGroup) {
164            $this->endGroup();
165        }
166
167        $this->currentGroup = $title;
168        $this->emit('group', $title);
169    }
170
171    /**
172     * Ends the current collapsible log group.
173     *
174     * @return void
175     */
176    public function endGroup(): void
177    {
178        if (null === $this->currentGroup) {
179            return;
180        }
181
182        $this->currentGroup = null;
183        $this->emit('endgroup');
184    }
185
186    /**
187     * Runs a callback wrapped inside a GitHub Actions log group.
188     *
189     * @template TResult
190     *
191     * @param string $title the group title
192     * @param callable(): TResult $callback the callback to execute within the group
193     *
194     * @return TResult
195     */
196    public function group(string $title, callable $callback): mixed
197    {
198        $this->startGroup($title);
199
200        try {
201            return $callback();
202        } finally {
203            $this->endGroup();
204        }
205    }
206
207    /**
208     * Emits a raw workflow command after escaping its properties and payload.
209     *
210     * @param string $command the GitHub workflow command name
211     * @param string $message the command message
212     * @param array<string, string> $properties the optional command properties
213     */
214    private function emit(string $command, string $message = '', array $properties = []): void
215    {
216        if (! $this->supportsWorkflowCommands()) {
217            return;
218        }
219
220        $command = $this->escapeProperty($command);
221        $message = $this->escapeData($message);
222
223        if ([] === $properties) {
224            $this->output->writeln(\sprintf('::%s::%s', $command, $message));
225
226            return;
227        }
228
229        $properties = array_map($this->escapeProperty(...), $properties);
230
231        $serializedProperties = [];
232
233        foreach ($properties as $name => $value) {
234            $serializedProperties[] = \sprintf('%s=%s', $name, $value);
235        }
236
237        $this->output->writeln(\sprintf('::%s %s::%s', $command, implode(',', $serializedProperties), $message));
238    }
239
240    /**
241     * Determines whether workflow commands should be emitted for the current process.
242     *
243     * The helper suppresses workflow commands during the PHPUnit suite even
244     * when the tests themselves run inside GitHub Actions.
245     *
246     * @return bool true when the current environment supports GitHub workflow commands
247     */
248    private function supportsWorkflowCommands(): bool
249    {
250        return $this->isTruthyEnvironmentFlag('GITHUB_ACTIONS')
251            && ! $this->isTruthyEnvironmentFlag('COMPOSER_TESTS_ARE_RUNNING');
252    }
253
254    /**
255     * Determines whether an environment flag is set to a truthy value.
256     *
257     * @param string $name the environment variable name
258     *
259     * @return bool true when the environment variable is truthy
260     */
261    private function isTruthyEnvironmentFlag(string $name): bool
262    {
263        $value = $this->environment->get($name, '');
264
265        return null !== $value && '' !== $value && '0' !== $value;
266    }
267
268    /**
269     * Escapes workflow-command payload data according to GitHub Actions rules.
270     *
271     * @param string $data
272     */
273    private function escapeData(string $data): string
274    {
275        $data = str_replace('%', '%25', $data);
276        $data = str_replace("\r", '%0D', $data);
277
278        return str_replace("\n", '%0A', $data);
279    }
280
281    /**
282     * Escapes workflow-command property values according to GitHub Actions rules.
283     *
284     * @param string $property
285     */
286    private function escapeProperty(string $property): string
287    {
288        $property = str_replace('%', '%25', $property);
289        $property = str_replace("\r", '%0D', $property);
290        $property = str_replace("\n", '%0A', $property);
291        $property = str_replace(':', '%3A', $property);
292
293        return str_replace(',', '%2C', $property);
294    }
295}