From ac5488086fe89e2b6a4510cfcd7e0ab2a21bedf8 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:50:25 -0400 Subject: [PATCH] [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`: image 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"'` --- crates/ruff/src/printer.rs | 39 +++++++------------ crates/ruff_db/src/diagnostic/mod.rs | 1 + crates/ruff_db/src/diagnostic/render.rs | 4 +- .../ruff_db/src/diagnostic/render/github.rs | 39 ++++++++++++++++--- ...nostic__render__github__tests__output.snap | 6 +-- ..._render__github__tests__syntax_errors.snap | 4 +- crates/ty/docs/cli.md | 1 + crates/ty/src/args.rs | 4 ++ crates/ty/tests/cli/main.rs | 24 ++++++++++++ crates/ty_project/src/metadata/options.rs | 5 +++ ty.schema.json | 7 ++++ 11 files changed, 95 insertions(+), 39 deletions(-) diff --git a/crates/ruff/src/printer.rs b/crates/ruff/src/printer.rs index bf18f66f41..2cfc0e7492 100644 --- a/crates/ruff/src/printer.rs +++ b/crates/ruff/src/printer.rs @@ -10,7 +10,8 @@ use ruff_linter::linter::FixTable; use serde::Serialize; 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::logging::LogLevel; @@ -224,32 +225,26 @@ impl Printer { let context = EmitterContext::new(&diagnostics.notebook_indexes); let fixables = FixableStatistics::try_from(diagnostics, self.unsafe_fixes); + let config = DisplayDiagnosticConfig::default().preview(preview); + match self.format { OutputFormat::Json => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Json) - .preview(preview); + let config = config.format(DiagnosticFormat::Json); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::Rdjson => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Rdjson) - .preview(preview); + let config = config.format(DiagnosticFormat::Rdjson); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::JsonLines => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::JsonLines) - .preview(preview); + let config = config.format(DiagnosticFormat::JsonLines); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::Junit => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Junit) - .preview(preview); + let config = config.format(DiagnosticFormat::Junit); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } @@ -288,30 +283,22 @@ impl Printer { self.write_summary_text(writer, diagnostics)?; } OutputFormat::Github => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Github) - .preview(preview); - let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); + let renderer = GithubRenderer::new(&context, "Ruff"); + let value = DisplayGithubDiagnostics::new(&renderer, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::Gitlab => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Gitlab) - .preview(preview); + let config = config.format(DiagnosticFormat::Gitlab); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::Pylint => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Pylint) - .preview(preview); + let config = config.format(DiagnosticFormat::Pylint); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } OutputFormat::Azure => { - let config = DisplayDiagnosticConfig::default() - .format(DiagnosticFormat::Azure) - .preview(preview); + let config = config.format(DiagnosticFormat::Azure); let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner); write!(writer, "{value}")?; } diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index f2c6c45785..99b8b9d83b 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -8,6 +8,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; pub use self::render::{ DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input, ceil_char_boundary, + github::{DisplayGithubDiagnostics, GithubRenderer}, }; use crate::{Db, files::File}; diff --git a/crates/ruff_db/src/diagnostic/render.rs b/crates/ruff_db/src/diagnostic/render.rs index 6eb87b8b7f..421ab4b9a3 100644 --- a/crates/ruff_db/src/diagnostic/render.rs +++ b/crates/ruff_db/src/diagnostic/render.rs @@ -31,7 +31,7 @@ use pylint::PylintRenderer; mod azure; mod concise; mod full; -mod github; +pub mod github; #[cfg(feature = "serde")] mod gitlab; #[cfg(feature = "serde")] @@ -145,7 +145,7 @@ impl std::fmt::Display for DisplayDiagnostics<'_> { gitlab::GitlabRenderer::new(self.resolver).render(f, self.diagnostics)?; } DiagnosticFormat::Github => { - GithubRenderer::new(self.resolver).render(f, self.diagnostics)?; + GithubRenderer::new(self.resolver, "ty").render(f, self.diagnostics)?; } } diff --git a/crates/ruff_db/src/diagnostic/render/github.rs b/crates/ruff_db/src/diagnostic/render/github.rs index da857c85d8..77bae347af 100644 --- a/crates/ruff_db/src/diagnostic/render/github.rs +++ b/crates/ruff_db/src/diagnostic/render/github.rs @@ -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, + program: &'a str, } impl<'a> GithubRenderer<'a> { - pub(super) fn new(resolver: &'a dyn FileResolver) -> Self { - Self { resolver } + pub fn new(resolver: &'a dyn FileResolver, program: &'a str) -> Self { + Self { resolver, program } } pub(super) fn render( @@ -15,9 +16,15 @@ impl<'a> GithubRenderer<'a> { diagnostics: &[Diagnostic], ) -> std::fmt::Result { for diagnostic in diagnostics { + let severity = match diagnostic.severity() { + Severity::Info => "notice", + Severity::Warning => "warning", + Severity::Error | Severity::Fatal => "error", + }; write!( f, - "::error title=Ruff ({code})", + "::{severity} title={program} ({code})", + program = self.program, 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)] mod tests { use crate::diagnostic::{ @@ -103,7 +130,7 @@ mod tests { insta::assert_snapshot!( env.render(&diag), - @"::error title=Ruff (test-diagnostic)::test-diagnostic: main diagnostic message", + @"::error title=ty (test-diagnostic)::test-diagnostic: main diagnostic message", ); } } diff --git a/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__output.snap b/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__output.snap index fcb1ea24ef..1d837f7707 100644 --- a/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__output.snap +++ b/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__output.snap @@ -2,6 +2,6 @@ source: crates/ruff_db/src/diagnostic/render/github.rs 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=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=Ruff (F821),file=undef.py,line=1,col=4,endLine=1,endColumn=5::undef.py:1:4: F821 Undefined name `a` +::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=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=ty (F821),file=undef.py,line=1,col=4,endLine=1,endColumn=5::undef.py:1:4: F821 Undefined name `a` diff --git a/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__syntax_errors.snap b/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__syntax_errors.snap index 76f824c2ef..50fead069b 100644 --- a/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__syntax_errors.snap +++ b/crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__github__tests__syntax_errors.snap @@ -2,5 +2,5 @@ source: crates/ruff_db/src/diagnostic/render/github.rs 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=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=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=3,col=12,endLine=4,endColumn=1::syntax_errors.py:3:12: invalid-syntax: Expected ')', found newline diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md index c02bb6ea4b..13465bfb0f 100644 --- a/crates/ty/docs/cli.md +++ b/crates/ty/docs/cli.md @@ -63,6 +63,7 @@ over all configuration files.

  • full: Print diagnostics verbosely, with context and helpful hints (default)
  • concise: Print diagnostics concisely, one per line
  • gitlab: Print diagnostics in the JSON format expected by GitLab Code Quality reports
  • +
  • github: Print diagnostics in the format used by GitHub Actions workflow error annotations
  • --project project

    Run the command within the given project directory.

    All pyproject.toml files will be discovered by walking up the directory tree from the given project directory, as will the project's virtual environment (.venv) unless the venv-path option is set.

    Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

    diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 1a2eabf5f3..8fac0a343a 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -324,6 +324,9 @@ pub enum OutputFormat { /// Print diagnostics in the JSON format expected by GitLab Code Quality reports. #[value(name = "gitlab")] Gitlab, + #[value(name = "github")] + /// Print diagnostics in the format used by GitHub Actions workflow error annotations. + Github, } impl From for ty_project::metadata::options::OutputFormat { @@ -332,6 +335,7 @@ impl From for ty_project::metadata::options::OutputFormat { OutputFormat::Full => Self::Full, OutputFormat::Concise => Self::Concise, OutputFormat::Gitlab => Self::Gitlab, + OutputFormat::Github => Self::Github, } } } diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 70d2859f8d..28569e0f2c 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -683,6 +683,30 @@ fn gitlab_diagnostics() -> anyhow::Result<()> { 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 test was introduced because changes were made to diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index d0770248fa..13eaed796d 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1077,6 +1077,10 @@ pub enum OutputFormat { /// /// [Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format 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 { @@ -1096,6 +1100,7 @@ impl From for DiagnosticFormat { OutputFormat::Full => Self::Full, OutputFormat::Concise => Self::Concise, OutputFormat::Gitlab => Self::Gitlab, + OutputFormat::Github => Self::Github, } } } diff --git a/ty.schema.json b/ty.schema.json index 3aee321617..feaa039d43 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -171,6 +171,13 @@ "enum": [ "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" + ] } ] },