feature #38982 [Console][Yaml] Linter: add Github annotations format for errors (ogizanagi)

This PR was squashed before being merged into the 5.3-dev branch.

Discussion
----------

[Console][Yaml] Linter: add Github annotations format for errors

| Q             | A
| ------------- | ---
| Branch?       | 5.x <!-- see below -->
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tickets       | N/A <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT
| Doc PR        | TODO

Github actions [can write errors and warning](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-error-message) directly in their output, which result into annotations into the Github checks. It can even provide a filename, line & col number, allowing to display the annnotations inside the PR diff directly, at the right place.

More advanced usage of annotations can be made using the [API](https://docs.github.com/en/free-pro-team@latest/rest/reference/checks#list-check-run-annotations), but regarding the linters provided in Symfony components, it seems the shortcut using output is a great way to enhance the integration with Github Actions.

This PR starts by proposing these changes in the yaml linter:
- add the `github` format, which is the same as the `txt` one, except for errors and warning, for which we'll adapt the output to the Github annotations format.
- remove the `txt` format as default, and autodetect if the script is running in a Github action context, then use `github` format. If it's not, we fallback to `txt` as before.

Once we agree on the details, we could perform the same for other linters (xliff, twig, ...)

Here is a PR using it: https://github.com/ogizanagi/symfony-lint-gha-demo/pull/2

and some screenshots:

| PR checks run | PR checks annotations | PR diff |
| -- | -- | -- |
| ![Capture d’écran 2020-11-04 à 09 37 07](https://user-images.githubusercontent.com/2211145/98089377-ed416600-1e82-11eb-8b10-40602b45efb1.png) | ![Capture d’écran 2020-11-04 à 09 37 28](https://user-images.githubusercontent.com/2211145/98089379-edd9fc80-1e82-11eb-8302-4e104abaeb2c.png) | ![Capture d’écran 2020-11-04 à 09 38 28](https://user-images.githubusercontent.com/2211145/98089381-edd9fc80-1e82-11eb-982a-9e4413ec30ba.png) |

~~(tests to add)~~

---

This was inspired by [PHPStan](d77bd87da9/src/Command/ErrorFormatter/GithubErrorFormatter.php) which is already auto-adapting the output according to the CI, using https://github.com/OndraM/ci-detector

Commits
-------

f0bbdc8d72 [Console][Yaml] Linter: add Github annotations format for errors
This commit is contained in:
Robin Chalas 2020-11-20 09:48:18 +01:00
commit 04eec8bfc7
6 changed files with 266 additions and 2 deletions

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.3.0
-----
* Added `GithubActionReporter` to render annotations in a Github Action
5.2.0
-----

View File

@ -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));
}
}

View File

@ -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'];
}
}

View 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
-----

View 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']);
}
}
}

View 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