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:
commit
04eec8bfc7
@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
5.3.0
|
||||
-----
|
||||
|
||||
* Added `GithubActionReporter` to render annotations in a Github Action
|
||||
|
||||
5.2.0
|
||||
-----
|
||||
|
||||
|
99
src/Symfony/Component/Console/CI/GithubActionReporter.php
Normal file
99
src/Symfony/Component/Console/CI/GithubActionReporter.php
Normal 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));
|
||||
}
|
||||
}
|
@ -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'];
|
||||
}
|
||||
}
|
@ -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
|
||||
-----
|
||||
|
||||
|
@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user