Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
GithubActionOutput
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 13
870
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
 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 Composer\Util\Platform;
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     */
39    public function __construct(
40        private  ConsoleOutputInterface $output,
41    ) {}
42
43    /**
44     * Closes any open group before the output instance is destroyed.
45     */
46    public function __destruct()
47    {
48        if (null !== $this->currentGroup) {
49            $this->endGroup();
50        }
51    }
52
53    /**
54     * Emits an error annotation for the current workflow step.
55     *
56     * @param string $message the annotation message
57     * @param string|null $file the related file when known
58     * @param int|null $line the related line when known
59     * @param int|null $column the related column when known
60     *
61     * @return void
62     */
63    public function error(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
64    {
65        $properties = [];
66
67        if (null !== $file) {
68            $properties['file'] = $file;
69        }
70
71        if (null !== $line) {
72            $properties['line'] = (string) $line;
73        }
74
75        if (null !== $column) {
76            $properties['col'] = (string) $column;
77        }
78
79        $this->emit('error', $message, $properties);
80    }
81
82    /**
83     * Emits a warning annotation for the current workflow step.
84     *
85     * @param string $message the annotation message
86     * @param string|null $file the related file when known
87     * @param int|null $line the related line when known
88     * @param int|null $column the related column when known
89     *
90     * @return void
91     */
92    public function warning(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
93    {
94        $properties = [];
95
96        if (null !== $file) {
97            $properties['file'] = $file;
98        }
99
100        if (null !== $line) {
101            $properties['line'] = (string) $line;
102        }
103
104        if (null !== $column) {
105            $properties['col'] = (string) $column;
106        }
107
108        $this->emit('warning', $message, $properties);
109    }
110
111    /**
112     * Emits a notice annotation for the current workflow step.
113     *
114     * @param string $message the annotation message
115     * @param string|null $file the related file when known
116     * @param int|null $line the related line when known
117     * @param int|null $column the related column when known
118     *
119     * @return void
120     */
121    public function notice(string $message, ?string $file = null, ?int $line = null, ?int $column = null): void
122    {
123        $properties = [];
124
125        if (null !== $file) {
126            $properties['file'] = $file;
127        }
128
129        if (null !== $line) {
130            $properties['line'] = (string) $line;
131        }
132
133        if (null !== $column) {
134            $properties['col'] = (string) $column;
135        }
136
137        $this->emit('notice', $message, $properties);
138    }
139
140    /**
141     * Emits a debug workflow command.
142     *
143     * @param string $message the debug message
144     *
145     * @return void
146     */
147    public function debug(string $message): void
148    {
149        $this->emit('debug', $message);
150    }
151
152    /**
153     * Starts a collapsible GitHub Actions log group.
154     *
155     * @param string $title the group title
156     *
157     * @return void
158     */
159    public function startGroup(string $title): void
160    {
161        if (null !== $this->currentGroup) {
162            $this->endGroup();
163        }
164
165        $this->currentGroup = $title;
166        $this->emit('group', $title);
167    }
168
169    /**
170     * Ends the current collapsible log group.
171     *
172     * @return void
173     */
174    public function endGroup(): void
175    {
176        if (null === $this->currentGroup) {
177            return;
178        }
179
180        $this->currentGroup = null;
181        $this->emit('endgroup');
182    }
183
184    /**
185     * Runs a callback wrapped inside a GitHub Actions log group.
186     *
187     * @template TResult
188     *
189     * @param string $title the group title
190     * @param callable(): TResult $callback the callback to execute within the group
191     *
192     * @return TResult
193     */
194    public function group(string $title, callable $callback): mixed
195    {
196        $this->startGroup($title);
197
198        try {
199            return $callback();
200        } finally {
201            $this->endGroup();
202        }
203    }
204
205    /**
206     * Emits a raw workflow command after escaping its properties and payload.
207     *
208     * @param string $command the GitHub workflow command name
209     * @param string $message the command message
210     * @param array<string, string> $properties the optional command properties
211     */
212    private function emit(string $command, string $message = '', array $properties = []): void
213    {
214        if (! $this->supportsWorkflowCommands()) {
215            return;
216        }
217
218        $command = $this->escapeProperty($command);
219        $message = $this->escapeData($message);
220
221        if ([] === $properties) {
222            $this->output->writeln(\sprintf('::%s::%s', $command, $message));
223
224            return;
225        }
226
227        $properties = array_map($this->escapeProperty(...), $properties);
228
229        $serializedProperties = [];
230
231        foreach ($properties as $name => $value) {
232            $serializedProperties[] = \sprintf('%s=%s', $name, $value);
233        }
234
235        $this->output->writeln(\sprintf('::%s %s::%s', $command, implode(',', $serializedProperties), $message));
236    }
237
238    /**
239     * Determines whether workflow commands should be emitted for the current process.
240     *
241     * The helper suppresses workflow commands during the PHPUnit suite even
242     * when the tests themselves run inside GitHub Actions.
243     *
244     * @return bool true when the current environment supports GitHub workflow commands
245     */
246    private function supportsWorkflowCommands(): bool
247    {
248        return (bool) Platform::getEnv('GITHUB_ACTIONS')
249            && ! (bool) Platform::getEnv('COMPOSER_TESTS_ARE_RUNNING');
250    }
251
252    /**
253     * Escapes workflow-command payload data according to GitHub Actions rules.
254     *
255     * @param string $data
256     */
257    private function escapeData(string $data): string
258    {
259        $data = str_replace('%', '%25', $data);
260        $data = str_replace("\r", '%0D', $data);
261
262        return str_replace("\n", '%0A', $data);
263    }
264
265    /**
266     * Escapes workflow-command property values according to GitHub Actions rules.
267     *
268     * @param string $property
269     */
270    private function escapeProperty(string $property): string
271    {
272        $property = str_replace('%', '%25', $property);
273        $property = str_replace("\r", '%0D', $property);
274        $property = str_replace("\n", '%0A', $property);
275        $property = str_replace(':', '%3A', $property);
276
277        return str_replace(',', '%2C', $property);
278    }
279}