[ty] Add GitHub output format (#20358)

## Summary

This PR wires up the GitHub output format moved to `ruff_db` in #20320
to the ty CLI.

It's a bit smaller than the GitLab version (#20155) because some of the
helpers were already in place, but I did factor out a few
`DisplayDiagnosticConfig` constructor calls in Ruff. I also exposed the
`GithubRenderer` and a wrapper `DisplayGithubDiagnostics` type because
we needed a way to configure the program name displayed in the GitHub
diagnostics. This was previously hard-coded to `Ruff`:

<img width="675" height="247" alt="image"
src="https://github.com/user-attachments/assets/592da860-d2f5-4abd-bc5a-66071d742509"
/>

Another option would be to drop the program name in the output format,
but I think it can be helpful in workflows with multiple programs
emitting annotations (such as Ruff and ty!)

## Test Plan

New CLI test, and a manual test with `--config 'terminal.output-format =
"github"'`
This commit is contained in:
Brent Westbrook 2025-09-17 09:50:25 -04:00 committed by GitHub
parent 7e464b8150
commit ac5488086f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 95 additions and 39 deletions

View file

@ -10,7 +10,8 @@ use ruff_linter::linter::FixTable;
use serde::Serialize; use serde::Serialize;
use ruff_db::diagnostic::{ use ruff_db::diagnostic::{
Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, DisplayDiagnostics, SecondaryCode, Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, DisplayDiagnostics,
DisplayGithubDiagnostics, GithubRenderer, SecondaryCode,
}; };
use ruff_linter::fs::relativize_path; use ruff_linter::fs::relativize_path;
use ruff_linter::logging::LogLevel; use ruff_linter::logging::LogLevel;
@ -224,32 +225,26 @@ impl Printer {
let context = EmitterContext::new(&diagnostics.notebook_indexes); let context = EmitterContext::new(&diagnostics.notebook_indexes);
let fixables = FixableStatistics::try_from(diagnostics, self.unsafe_fixes); let fixables = FixableStatistics::try_from(diagnostics, self.unsafe_fixes);
let config = DisplayDiagnosticConfig::default().preview(preview);
match self.format { match self.format {
OutputFormat::Json => { OutputFormat::Json => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Json);
.format(DiagnosticFormat::Json)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::Rdjson => { OutputFormat::Rdjson => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Rdjson);
.format(DiagnosticFormat::Rdjson)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::JsonLines => { OutputFormat::JsonLines => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::JsonLines);
.format(DiagnosticFormat::JsonLines)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::Junit => { OutputFormat::Junit => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Junit);
.format(DiagnosticFormat::Junit)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
@ -288,30 +283,22 @@ impl Printer {
self.write_summary_text(writer, diagnostics)?; self.write_summary_text(writer, diagnostics)?;
} }
OutputFormat::Github => { OutputFormat::Github => {
let config = DisplayDiagnosticConfig::default() let renderer = GithubRenderer::new(&context, "Ruff");
.format(DiagnosticFormat::Github) let value = DisplayGithubDiagnostics::new(&renderer, &diagnostics.inner);
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::Gitlab => { OutputFormat::Gitlab => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Gitlab);
.format(DiagnosticFormat::Gitlab)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::Pylint => { OutputFormat::Pylint => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Pylint);
.format(DiagnosticFormat::Pylint)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }
OutputFormat::Azure => { OutputFormat::Azure => {
let config = DisplayDiagnosticConfig::default() let config = config.format(DiagnosticFormat::Azure);
.format(DiagnosticFormat::Azure)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?; write!(writer, "{value}")?;
} }

View file

@ -8,6 +8,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
pub use self::render::{ pub use self::render::{
DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input, ceil_char_boundary, DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input, ceil_char_boundary,
github::{DisplayGithubDiagnostics, GithubRenderer},
}; };
use crate::{Db, files::File}; use crate::{Db, files::File};

View file

@ -31,7 +31,7 @@ use pylint::PylintRenderer;
mod azure; mod azure;
mod concise; mod concise;
mod full; mod full;
mod github; pub mod github;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
mod gitlab; mod gitlab;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
@ -145,7 +145,7 @@ impl std::fmt::Display for DisplayDiagnostics<'_> {
gitlab::GitlabRenderer::new(self.resolver).render(f, self.diagnostics)?; gitlab::GitlabRenderer::new(self.resolver).render(f, self.diagnostics)?;
} }
DiagnosticFormat::Github => { DiagnosticFormat::Github => {
GithubRenderer::new(self.resolver).render(f, self.diagnostics)?; GithubRenderer::new(self.resolver, "ty").render(f, self.diagnostics)?;
} }
} }

View file

@ -1,12 +1,13 @@
use crate::diagnostic::{Diagnostic, FileResolver}; use crate::diagnostic::{Diagnostic, FileResolver, Severity};
pub(super) struct GithubRenderer<'a> { pub struct GithubRenderer<'a> {
resolver: &'a dyn FileResolver, resolver: &'a dyn FileResolver,
program: &'a str,
} }
impl<'a> GithubRenderer<'a> { impl<'a> GithubRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver) -> Self { pub fn new(resolver: &'a dyn FileResolver, program: &'a str) -> Self {
Self { resolver } Self { resolver, program }
} }
pub(super) fn render( pub(super) fn render(
@ -15,9 +16,15 @@ impl<'a> GithubRenderer<'a> {
diagnostics: &[Diagnostic], diagnostics: &[Diagnostic],
) -> std::fmt::Result { ) -> std::fmt::Result {
for diagnostic in diagnostics { for diagnostic in diagnostics {
let severity = match diagnostic.severity() {
Severity::Info => "notice",
Severity::Warning => "warning",
Severity::Error | Severity::Fatal => "error",
};
write!( write!(
f, f,
"::error title=Ruff ({code})", "::{severity} title={program} ({code})",
program = self.program,
code = diagnostic.secondary_code_or_id() code = diagnostic.secondary_code_or_id()
)?; )?;
@ -75,6 +82,26 @@ impl<'a> GithubRenderer<'a> {
} }
} }
pub struct DisplayGithubDiagnostics<'a> {
renderer: &'a GithubRenderer<'a>,
diagnostics: &'a [Diagnostic],
}
impl<'a> DisplayGithubDiagnostics<'a> {
pub fn new(renderer: &'a GithubRenderer<'a>, diagnostics: &'a [Diagnostic]) -> Self {
Self {
renderer,
diagnostics,
}
}
}
impl std::fmt::Display for DisplayGithubDiagnostics<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.renderer.render(f, self.diagnostics)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::diagnostic::{ use crate::diagnostic::{
@ -103,7 +130,7 @@ mod tests {
insta::assert_snapshot!( insta::assert_snapshot!(
env.render(&diag), env.render(&diag),
@"::error title=Ruff (test-diagnostic)::test-diagnostic: main diagnostic message", @"::error title=ty (test-diagnostic)::test-diagnostic: main diagnostic message",
); );
} }
} }

View file

@ -2,6 +2,6 @@
source: crates/ruff_db/src/diagnostic/render/github.rs source: crates/ruff_db/src/diagnostic/render/github.rs
expression: env.render_diagnostics(&diagnostics) expression: env.render_diagnostics(&diagnostics)
--- ---
::error title=Ruff (F401),file=fib.py,line=1,col=8,endLine=1,endColumn=10::fib.py:1:8: F401 `os` imported but unused ::error title=ty (F401),file=fib.py,line=1,col=8,endLine=1,endColumn=10::fib.py:1:8: F401 `os` imported but unused
::error title=Ruff (F841),file=fib.py,line=6,col=5,endLine=6,endColumn=6::fib.py:6:5: F841 Local variable `x` is assigned to but never used ::error title=ty (F841),file=fib.py,line=6,col=5,endLine=6,endColumn=6::fib.py:6:5: F841 Local variable `x` is assigned to but never used
::error title=Ruff (F821),file=undef.py,line=1,col=4,endLine=1,endColumn=5::undef.py:1:4: F821 Undefined name `a` ::error title=ty (F821),file=undef.py,line=1,col=4,endLine=1,endColumn=5::undef.py:1:4: F821 Undefined name `a`

View file

@ -2,5 +2,5 @@
source: crates/ruff_db/src/diagnostic/render/github.rs source: crates/ruff_db/src/diagnostic/render/github.rs
expression: env.render_diagnostics(&diagnostics) expression: env.render_diagnostics(&diagnostics)
--- ---
::error title=Ruff (invalid-syntax),file=syntax_errors.py,line=1,col=15,endLine=2,endColumn=1::syntax_errors.py:1:15: invalid-syntax: Expected one or more symbol names after import ::error title=ty (invalid-syntax),file=syntax_errors.py,line=1,col=15,endLine=2,endColumn=1::syntax_errors.py:1:15: invalid-syntax: Expected one or more symbol names after import
::error title=Ruff (invalid-syntax),file=syntax_errors.py,line=3,col=12,endLine=4,endColumn=1::syntax_errors.py:3:12: invalid-syntax: Expected ')', found newline ::error title=ty (invalid-syntax),file=syntax_errors.py,line=3,col=12,endLine=4,endColumn=1::syntax_errors.py:3:12: invalid-syntax: Expected ')', found newline

1
crates/ty/docs/cli.md generated
View file

@ -63,6 +63,7 @@ over all configuration files.</p>
<li><code>full</code>: Print diagnostics verbosely, with context and helpful hints (default)</li> <li><code>full</code>: Print diagnostics verbosely, with context and helpful hints (default)</li>
<li><code>concise</code>: Print diagnostics concisely, one per line</li> <li><code>concise</code>: Print diagnostics concisely, one per line</li>
<li><code>gitlab</code>: Print diagnostics in the JSON format expected by GitLab Code Quality reports</li> <li><code>gitlab</code>: Print diagnostics in the JSON format expected by GitLab Code Quality reports</li>
<li><code>github</code>: Print diagnostics in the format used by GitHub Actions workflow error annotations</li>
</ul></dd><dt id="ty-check--project"><a href="#ty-check--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p> </ul></dd><dt id="ty-check--project"><a href="#ty-check--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code> files will be discovered by walking up the directory tree from the given project directory, as will the project's virtual environment (<code>.venv</code>) unless the <code>venv-path</code> option is set.</p> <p>All <code>pyproject.toml</code> files will be discovered by walking up the directory tree from the given project directory, as will the project's virtual environment (<code>.venv</code>) unless the <code>venv-path</code> option is set.</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p> <p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>

View file

@ -324,6 +324,9 @@ pub enum OutputFormat {
/// Print diagnostics in the JSON format expected by GitLab Code Quality reports. /// Print diagnostics in the JSON format expected by GitLab Code Quality reports.
#[value(name = "gitlab")] #[value(name = "gitlab")]
Gitlab, Gitlab,
#[value(name = "github")]
/// Print diagnostics in the format used by GitHub Actions workflow error annotations.
Github,
} }
impl From<OutputFormat> for ty_project::metadata::options::OutputFormat { impl From<OutputFormat> for ty_project::metadata::options::OutputFormat {
@ -332,6 +335,7 @@ impl From<OutputFormat> for ty_project::metadata::options::OutputFormat {
OutputFormat::Full => Self::Full, OutputFormat::Full => Self::Full,
OutputFormat::Concise => Self::Concise, OutputFormat::Concise => Self::Concise,
OutputFormat::Gitlab => Self::Gitlab, OutputFormat::Gitlab => Self::Gitlab,
OutputFormat::Github => Self::Github,
} }
} }
} }

View file

@ -683,6 +683,30 @@ fn gitlab_diagnostics() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[test]
fn github_diagnostics() -> anyhow::Result<()> {
let case = CliTest::with_file(
"test.py",
r#"
print(x) # [unresolved-reference]
print(4[1]) # [non-subscriptable]
"#,
)?;
assert_cmd_snapshot!(case.command().arg("--output-format=github").arg("--warn").arg("unresolved-reference"), @r"
success: false
exit_code: 1
----- stdout -----
::warning title=ty (unresolved-reference),file=test.py,line=2,col=7,endLine=2,endColumn=8::test.py:2:7: unresolved-reference: Name `x` used when not defined
::error title=ty (non-subscriptable),file=test.py,line=3,col=7,endLine=3,endColumn=8::test.py:3:7: non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// This tests the diagnostic format for revealed type. /// This tests the diagnostic format for revealed type.
/// ///
/// This test was introduced because changes were made to /// This test was introduced because changes were made to

View file

@ -1077,6 +1077,10 @@ pub enum OutputFormat {
/// ///
/// [Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format /// [Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format
Gitlab, Gitlab,
/// Print diagnostics in the format used by [GitHub Actions] workflow error annotations.
///
/// [GitHub Actions]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-error-message
Github,
} }
impl OutputFormat { impl OutputFormat {
@ -1096,6 +1100,7 @@ impl From<OutputFormat> for DiagnosticFormat {
OutputFormat::Full => Self::Full, OutputFormat::Full => Self::Full,
OutputFormat::Concise => Self::Concise, OutputFormat::Concise => Self::Concise,
OutputFormat::Gitlab => Self::Gitlab, OutputFormat::Gitlab => Self::Gitlab,
OutputFormat::Github => Self::Github,
} }
} }
} }

7
ty.schema.json generated
View file

@ -171,6 +171,13 @@
"enum": [ "enum": [
"gitlab" "gitlab"
] ]
},
{
"description": "Print diagnostics in the format used by [GitHub Actions] workflow error annotations.\n\n[GitHub Actions]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-error-message",
"type": "string",
"enum": [
"github"
]
} }
] ]
}, },