[Console][Yaml] Linter: add Github annotations format for errors
authorMaxime Steinhausser <maxime.steinhausser@elao.com>
Tue, 3 Nov 2020 16:31:16 +0000 (17:31 +0100)
committerRobin Chalas <robin.chalas@gmail.com>
Fri, 20 Nov 2020 08:48:10 +0000 (09:48 +0100)
src/Symfony/Component/Console/CHANGELOG.md
src/Symfony/Component/Console/CI/GithubActionReporter.php [new file with mode: 0644]
src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php [new file with mode: 0644]
src/Symfony/Component/Yaml/CHANGELOG.md
src/Symfony/Component/Yaml/Command/LintCommand.php
src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php

index c5a6963..afa0045 100644 (file)
@@ -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 (file)
index 0000000..0ae18ca
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <maxime.steinhausser@gmail.com>
+ */
+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 (file)
index 0000000..4325508
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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'];
+    }
+}
index d4f2b5d..baabf8a 100644 (file)
@@ -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
 -----
 
index 83f36a9..94a84b7 100644 (file)
@@ -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(<<<EOF
 The <info>%command.name%</info> 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('<info>OK</info>'.($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']);
+                }
             }
         }
 
index 32dd30d..6060b8f 100644 (file)
@@ -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 = <<<YAML
+foo:
+bar
+YAML;
+        $tester = $this->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 = <<<YAML
+foo:
+bar
+YAML;
+            $tester = $this->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 = <<<YAML