8000 [Console][Yaml] Linter: add Github annotations format for errors · symfony/symfony@f0bbdc8 · GitHub
[go: up one dir, main page]

Skip to content

Commit f0bbdc8

Browse files
ogizanagichalasr
authored andcommitted
[Console][Yaml] Linter: add Github annotations format for errors
1 parent 2a453c2 commit f0bbdc8

File tree

6 files changed

+266
-2
lines changed

6 files changed

+266
-2
lines changed

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
5.3.0
5+
-----
6+
7+
* Added `GithubActionReporter` to render annotations in a Github Action
8+
49
5.2.0
510
-----
611

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\CI;
13+
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
/**
17+
* Utility class for Github actions.
18+
*
19+
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
20+
*/
21+
class GithubActionReporter
22+
{
23+
private $output;
24+
25+
/**
26+
* @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L80-L85
27+
*/
28+
private const ESCAPED_DATA = [
29+
'%' => '%25',
30+
"\r" => '%0D',
31+
"\n" => '%0A',
32+
];
33+
34+
/**
35+
* @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L87-L94
36+
*/
37+
private const ESCAPED_PROPERTIES = [
38+
'%' => '%25',
39+
"\r" => '%0D',
40+
"\n" => '%0A',
41+
':' => '%3A',
42+
',' => '%2C',
43+
];
44+
45+
public function __construct(OutputInterface $output)
46+
{
47+
$this->output = $output;
48+
}
49+
50+
public static function isGithubActionEnvironment(): bool
51+
{
52+
return false !== getenv('GITHUB_ACTIONS');
53+
}
54+
55+
/**
56+
* Output an error using the Github annotations format.
57+
*
58+
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
59+
*/
60+
public function error(string $message, string $file = null, int $line = null, int $col = null): void
61+
{
62+
$this->log('error', $message, $file, $line, $col);
63+
}
64+
65+
/**
66+
* Output a warning using the Github annotations format.
67+
*
68+
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message
69+
*/
70+
public function warning(string $message, string $file = null, int $line = null, int $col = null): void
71+
{
72+
$this->log('warning', $message, $file, $line, $col);
73+
}
74+
75+
/**
76+
* Output a debug log using the Github annotations format.
77+
*
78+
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message
79+
*/
80+
public function debug(string $message, string $file = null, int $line = null, int $col = null): void
81+
{
82+
$this->log('debug', $message, $file, $line, $col);
83+
}
84+
85+
private function log(string $type, string $message, string $file = null, int $line = null, int $col = null): void
86+
{
87+
// Some values must be encoded.
88+
$message = strtr($message, self::ESCAPED_DATA);
89+
90+
if (!$file) {
91+
// No file provided, output the message solely:
92+
$this->output->writeln(sprintf('::%s::%s', $type, $message));
93+
94+
return;
95+
}
96+
97+
$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));
98+
}
99+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Tests\CI;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Console\CI\GithubActionReporter;
16+
use Symfony\Component\Console\Output\BufferedOutput;
17+
18+
class GithubActionReporterTest extends TestCase
19+
{
20+
public function testIsGithubActionEnvironment()
21+
{
22+
$prev = getenv('GITHUB_ACTIONS');
23+
putenv('GITHUB_ACTIONS');
24+
25+
try {
26+
self::assertFalse(GithubActionReporter::isGithubActionEnvironment());
27+
97AE putenv('GITHUB_ACTIONS=1');
28+
self::assertTrue(GithubActionReporter::isGithubActionEnvironment());
29+
} finally {
30+
putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : ''));
31+
}
32+
}
33+
34+
/**
35+
* @dataProvider annotationsFormatProvider
36+
*/
37+
public function testAnnotationsFormat(string $type, string $message, string $file = null, int $line = null, int $col = null, string $expected)
38+
{
39+
$reporter = new GithubActionReporter($buffer = new BufferedOutput());
40+
41+
$reporter->{$type}($message, $file, $line, $col);
42+
43+
self::assertSame($expected.\PHP_EOL, $buffer->fetch());
44+
}
45+
46+
public function annotationsFormatProvider(): iterable
47+
{
48+
yield 'warning' => ['warning', 'A warning', null, null, null, '::warning::A warning'];
49+
yield 'error' => ['error', 'An error', null, null, null, '::error::An error'];
50+
yield 'debug' => ['debug', 'A debug log', null, null, null, '::debug::A debug log'];
51+
52+
yield 'with message to escape' => [
53+
'debug',
54+
"There are 100% chances\nfor this to be escaped properly\rRight?",
55+
null,
56+
null,
57+
null,
58+
'::debug::There are 100%25 chances%0Afor this to be escaped properly%0DRight?',
59+
];
60+
61+
yield 'with meta' => [
62+
'warning',
63+
'A warning',
64+
'foo/bar.php',
65+
2,
66+
4,
67+
'::warning file=foo/bar.php, line=2, col=4::A warning',
68+
];
69+
70+
yield 'with file property to escape' => [
71+
'warning',
72+
'A warning',
73+
'foo,bar:baz%quz.php',
74+
2,
75+
4,
76+
'::warning file=foo%2Cbar%3Abaz%25quz.php, line=2, col=4::A warning',
77+
];
78+
79+
yield 'without file ignores col & line' => ['warning', 'A warning', null, 2, 4, '::warning::A warning'];
80+
}
81+
}

src/Symfony/Component/Yaml/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
5.3.0
5+
-----
6+
7+
* Added `github` format support & autodetection to render errors as annotations
8+
when running the YAML linter command in a Github Action environment.
9+
410
5.1.0
511
-----
612

src/Symfony/Component/Yaml/Command/LintCommand.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Yaml\Command;
1313

14+
use Symfony\Component\Console\CI\GithubActionReporter;
1415
use Symfony\Component\Console\Command\Command;
1516
use Symfony\Component\Console\Exception\InvalidArgumentException;
1617
use Symfony\Component\Console\Exception\RuntimeException;
@@ -55,7 +56,7 @@ protected function configure()
5556
$this
5657
->setDescription('Lints a file and outputs encountered errors')
5758
->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN')
58-
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt')
59+
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format')
5960
->addOption('parse-tags', null, InputOption::VALUE_NONE, 'Parse custom tags')
6061
->setHelp(<<<EOF
6162
The <info>%command.name%</info> command lints a YAML file and outputs to STDOUT
@@ -84,6 +85,16 @@ protected function execute(InputInterface $input, OutputInterface $output)
8485
$io = new SymfonyStyle($input, $output);
8586
$filenames = (array) $input->getArgument('filename');
8687
$this->format = $input->getOption('format');
88+
89+
if ('github' === $this->format && !class_exists(GithubActionReporter::class)) {
90+
throw new \InvalidArgumentException('The "github" format is only available since "symfony/console" >= 5.3.');
91+
}
92+
93+
if (null === $this->format) {
94+
// Autodetect format according to CI environment
95+
$this->format = class_exists(GithubActionReporter::class) && GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt';
96+
}
97+
8798
$this->displayCorrectFiles = $output->isVerbose();
8899
$flags = $input->getOption('parse-tags') ? Yaml::PARSE_CUSTOM_TAGS : 0;
89100

@@ -137,17 +148,23 @@ private function display(SymfonyStyle $io, array $files): int
137148
return $this->displayTxt($io, $files);
138149
case 'json':
139150
return $this->displayJson($io, $files);
151+
case 'github':
152+
return $this->displayTxt($io, $files, true);
140153
default:
141154
throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format));
142155
}
143156
}
144157

145-
private function displayTxt(SymfonyStyle $io, array $filesInfo): int
158+
private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int
146159
{
147160
$countFiles = \count($filesInfo);
148161
$erroredFiles = 0;
149162
$suggestTagOption = false;
150163

164+
if ($errorAsGithubAnnotations) {
165+
$githubReporter = new GithubActionReporter($io);
166+
}
167+
151168
foreach ($filesInfo as $info) {
152169
if ($info['valid'] && $this->displayCorrectFiles) {
153170
$io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : ''));
@@ -159,6 +176,10 @@ private function displayTxt(SymfonyStyle $io, array $filesInfo): int
159176
if (false !== strpos($info['message'], 'PARSE_CUSTOM_TAGS')) {
160177
$suggestTagOption = true;
161178
}
179+
180+
if ($errorAsGithubAnnotations) {
181+
$githubReporter->error($info['message'], $info['file'] ?? 'php://stdin', $info['line']);
182+
}
162183
}
163184
}
164185

src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Console\Application;
16+
use Symfony\Component\Console\CI\GithubActionReporter;
1617
use Symfony\Component\Console\Output\OutputInterface;
1718
use Symfony\Component\Console\Tester\CommandTester;
1819
use Symfony\Component\Yaml\Command\LintCommand;
@@ -63,6 +64,57 @@ public function testLintIncorrectFile()
6364
$this->assertStringContainsString('Unable to parse at line 3 (near "bar").', trim($tester->getDisplay()));
6465
}
6566

67+
public function testLintIncorrectFileWithGithubFormat()
68+
{
69+
if (!class_exists(GithubActionReporter::class)) {
70+
$this->expectException(\InvalidArgumentException::class);
71+
$this->expectExceptionMessage('The "github" format is only available since "symfony/console" >= 5.3.');
72+
}
73+
74+
$incorrectContent = <<<YAML
75+
foo:
76+
bar
77+
YAML;
78+
$tester = $this->createCommandTester();
79+
$filename = $this->createFile($incorrectContent);
80+
81+
$tester->execute(['filename' => $filename, '--format' => 'github'], ['decorated' => false]);
82+
83+
if (!class_exists(GithubActionReporter::class)) {
84+
return;
85+
}
86+
87+
self::assertEquals(1, $tester->getStatusCode(), 'Returns 1 in case of error');
88+
self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay()));
89+
}
90+
91+
public function testLintAutodetectsGithubActionEnvironment()
92+
{
93+
if (!class_exists(GithubActionReporter::class)) {
94+
$this->markTestSkipped('The "github" format is only available since "symfony/console" >= 5.3.');
95+
}
96+
97+
$prev = getenv('GITHUB_ACTIONS');
98+
putenv('GITHUB_ACTIONS');
99+
100+
try {
101+
putenv('GITHUB_ACTIONS=1');
102+
103+
$incorrectContent = <<<YAML
104+
foo:
105+
bar
106+
YAML;
107+
$tester = $this->createCommandTester();
108+
$filename = $this->createFile($incorrectContent);
109+
110+
$tester->execute(['filename' => $filename], ['decorated' => false]);
111+
112+
self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay()));
113+
} finally {
114+
putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : ''));
115+
}
116+
}
117+
66118
public function testConstantAsKey()
67119
{
68120
$yaml = <<<YAML

0 commit comments

Comments
 (0)
0