diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index c5a69637e1..afa00450d2 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3.0 +----- + + * Added `GithubActionReporter` to render annotations in a Github Action + 5.2.0 ----- diff --git a/src/Symfony/Component/Console/CI/GithubActionReporter.php b/src/Symfony/Component/Console/CI/GithubActionReporter.php new file mode 100644 index 0000000000..0ae18ca15e --- /dev/null +++ b/src/Symfony/Component/Console/CI/GithubActionReporter.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\CI; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Utility class for Github actions. + * + * @author Maxime Steinhausser + */ +class GithubActionReporter +{ + private $output; + + /** + * @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L80-L85 + */ + private const ESCAPED_DATA = [ + '%' => '%25', + "\r" => '%0D', + "\n" => '%0A', + ]; + + /** + * @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L87-L94 + */ + private const ESCAPED_PROPERTIES = [ + '%' => '%25', + "\r" => '%0D', + "\n" => '%0A', + ':' => '%3A', + ',' => '%2C', + ]; + + public function __construct(OutputInterface $output) + { + $this->output = $output; + } + + public static function isGithubActionEnvironment(): bool + { + return false !== getenv('GITHUB_ACTIONS'); + } + + /** + * Output an error using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + */ + public function error(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('error', $message, $file, $line, $col); + } + + /** + * Output a warning using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message + */ + public function warning(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('warning', $message, $file, $line, $col); + } + + /** + * Output a debug log using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message + */ + public function debug(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('debug', $message, $file, $line, $col); + } + + private function log(string $type, string $message, string $file = null, int $line = null, int $col = null): void + { + // Some values must be encoded. + $message = strtr($message, self::ESCAPED_DATA); + + if (!$file) { + // No file provided, output the message solely: + $this->output->writeln(sprintf('::%s::%s', $type, $message)); + + return; + } + + $this->output->writeln(sprintf('::%s file=%s, line=%s, col=%s::%s', $type, strtr($file, self::ESCAPED_PROPERTIES), strtr($line ?? 1, self::ESCAPED_PROPERTIES), strtr($col ?? 0, self::ESCAPED_PROPERTIES), $message)); + } +} diff --git a/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php b/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php new file mode 100644 index 0000000000..4325508399 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\CI; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\CI\GithubActionReporter; +use Symfony\Component\Console\Output\BufferedOutput; + +class GithubActionReporterTest extends TestCase +{ + public function testIsGithubActionEnvironment() + { + $prev = getenv('GITHUB_ACTIONS'); + putenv('GITHUB_ACTIONS'); + + try { + self::assertFalse(GithubActionReporter::isGithubActionEnvironment()); + putenv('GITHUB_ACTIONS=1'); + self::assertTrue(GithubActionReporter::isGithubActionEnvironment()); + } finally { + putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : '')); + } + } + + /** + * @dataProvider annotationsFormatProvider + */ + public function testAnnotationsFormat(string $type, string $message, string $file = null, int $line = null, int $col = null, string $expected) + { + $reporter = new GithubActionReporter($buffer = new BufferedOutput()); + + $reporter->{$type}($message, $file, $line, $col); + + self::assertSame($expected.\PHP_EOL, $buffer->fetch()); + } + + public function annotationsFormatProvider(): iterable + { + yield 'warning' => ['warning', 'A warning', null, null, null, '::warning::A warning']; + yield 'error' => ['error', 'An error', null, null, null, '::error::An error']; + yield 'debug' => ['debug', 'A debug log', null, null, null, '::debug::A debug log']; + + yield 'with message to escape' => [ + 'debug', + "There are 100% chances\nfor this to be escaped properly\rRight?", + null, + null, + null, + '::debug::There are 100%25 chances%0Afor this to be escaped properly%0DRight?', + ]; + + yield 'with meta' => [ + 'warning', + 'A warning', + 'foo/bar.php', + 2, + 4, + '::warning file=foo/bar.php, line=2, col=4::A warning', + ]; + + yield 'with file property to escape' => [ + 'warning', + 'A warning', + 'foo,bar:baz%quz.php', + 2, + 4, + '::warning file=foo%2Cbar%3Abaz%25quz.php, line=2, col=4::A warning', + ]; + + yield 'without file ignores col & line' => ['warning', 'A warning', null, 2, 4, '::warning::A warning']; + } +} diff --git a/src/Symfony/Component/Yaml/CHANGELOG.md b/src/Symfony/Component/Yaml/CHANGELOG.md index d4f2b5d781..baabf8a756 100644 --- a/src/Symfony/Component/Yaml/CHANGELOG.md +++ b/src/Symfony/Component/Yaml/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3.0 +----- + + * Added `github` format support & autodetection to render errors as annotations + when running the YAML linter command in a Github Action environment. + 5.1.0 ----- diff --git a/src/Symfony/Component/Yaml/Command/LintCommand.php b/src/Symfony/Component/Yaml/Command/LintCommand.php index 83f36a9383..94a84b754d 100644 --- a/src/Symfony/Component/Yaml/Command/LintCommand.php +++ b/src/Symfony/Component/Yaml/Command/LintCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Yaml\Command; +use Symfony\Component\Console\CI\GithubActionReporter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\RuntimeException; @@ -55,7 +56,7 @@ class LintCommand extends Command $this ->setDescription('Lints a file and outputs encountered errors') ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') - ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format') ->addOption('parse-tags', null, InputOption::VALUE_NONE, 'Parse custom tags') ->setHelp(<<%command.name% command lints a YAML file and outputs to STDOUT @@ -84,6 +85,16 @@ EOF $io = new SymfonyStyle($input, $output); $filenames = (array) $input->getArgument('filename'); $this->format = $input->getOption('format'); + + if ('github' === $this->format && !class_exists(GithubActionReporter::class)) { + throw new \InvalidArgumentException('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + if (null === $this->format) { + // Autodetect format according to CI environment + $this->format = class_exists(GithubActionReporter::class) && GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'; + } + $this->displayCorrectFiles = $output->isVerbose(); $flags = $input->getOption('parse-tags') ? Yaml::PARSE_CUSTOM_TAGS : 0; @@ -137,17 +148,23 @@ EOF return $this->displayTxt($io, $files); case 'json': return $this->displayJson($io, $files); + case 'github': + return $this->displayTxt($io, $files, true); default: throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format)); } } - private function displayTxt(SymfonyStyle $io, array $filesInfo): int + private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int { $countFiles = \count($filesInfo); $erroredFiles = 0; $suggestTagOption = false; + if ($errorAsGithubAnnotations) { + $githubReporter = new GithubActionReporter($io); + } + foreach ($filesInfo as $info) { if ($info['valid'] && $this->displayCorrectFiles) { $io->comment('OK'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); @@ -159,6 +176,10 @@ EOF if (false !== strpos($info['message'], 'PARSE_CUSTOM_TAGS')) { $suggestTagOption = true; } + + if ($errorAsGithubAnnotations) { + $githubReporter->error($info['message'], $info['file'] ?? 'php://stdin', $info['line']); + } } } diff --git a/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php b/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php index 32dd30d495..6060b8fcb5 100644 --- a/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php +++ b/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Yaml\Tests\Command; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; +use Symfony\Component\Console\CI\GithubActionReporter; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Yaml\Command\LintCommand; @@ -63,6 +64,57 @@ bar'; $this->assertStringContainsString('Unable to parse at line 3 (near "bar").', trim($tester->getDisplay())); } + public function testLintIncorrectFileWithGithubFormat() + { + if (!class_exists(GithubActionReporter::class)) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + $incorrectContent = <<createCommandTester(); + $filename = $this->createFile($incorrectContent); + + $tester->execute(['filename' => $filename, '--format' => 'github'], ['decorated' => false]); + + if (!class_exists(GithubActionReporter::class)) { + return; + } + + self::assertEquals(1, $tester->getStatusCode(), 'Returns 1 in case of error'); + self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay())); + } + + public function testLintAutodetectsGithubActionEnvironment() + { + if (!class_exists(GithubActionReporter::class)) { + $this->markTestSkipped('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + $prev = getenv('GITHUB_ACTIONS'); + putenv('GITHUB_ACTIONS'); + + try { + putenv('GITHUB_ACTIONS=1'); + + $incorrectContent = <<createCommandTester(); + $filename = $this->createFile($incorrectContent); + + $tester->execute(['filename' => $filename], ['decorated' => false]); + + self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay())); + } finally { + putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : '')); + } + } + public function testConstantAsKey() { $yaml = <<