From f0bbdc8d722eca3782bc43e4cb5889ce5f29c3bb Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Tue, 3 Nov 2020 17:31:16 +0100 Subject: [PATCH] [Console][Yaml] Linter: add Github annotations format for errors --- src/Symfony/Component/Console/CHANGELOG.md | 5 + .../Console/CI/GithubActionReporter.php | 99 +++++++++++++++++++ .../Tests/CI/GithubActionReporterTest.php | 81 +++++++++++++++ src/Symfony/Component/Yaml/CHANGELOG.md | 6 ++ .../Component/Yaml/Command/LintCommand.php | 25 ++++- .../Yaml/Tests/Command/LintCommandTest.php | 52 ++++++++++ 6 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/Console/CI/GithubActionReporter.php create mode 100644 src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index c5a69637e1ba8..afa00450d233c 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 0000000000000..0ae18ca15e8a0 --- /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 0000000000000..4325508399113 --- /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 d4f2b5d781fc6..baabf8a756853 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 83f36a93839d2..94a84b754d213 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 @@ protected function configure() $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 @@ protected function execute(InputInterface $input, OutputInterface $output) $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 @@ private function display(SymfonyStyle $io, array $files): int 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 @@ private function displayTxt(SymfonyStyle $io, array $filesInfo): int 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 32dd30d495a84..6060b8fcb5518 100644 --- a/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php +++ b/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php @@ -13,6 +13,7 @@ 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 @@ public function testLintIncorrectFile() $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 = << pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy