Repository: codingjoe/relint

Issue:
Add alternative format for GitHub PR annotations
First of all, thank you for the amazing Swiss army knife that is reLint! 🙏 💙 

We use it to augment the linting that SQLFluff provides, since it only covers the basics (and not all of our SQL coding standards).

This feature request is basically to be able to easily post reLint results as annotation in GitHub PRs. Basically, [something similar to what SQLFluff is doing](https://docs.sqlfluff.com/en/2.2.0/production.html#using-github-actions-to-annotate-prs): `--format github-annotation-native`.

Or do you think this is something that belongs outside of the reLint codebase? (kinda like [container images](https://github.com/codingjoe/relint/discussions/59))

Discussion:

Patch:
diff --git a/relint/__main__.py b/relint/__main__.py
index afb162f..ed831d1 100644
--- a/relint/__main__.py
+++ b/relint/__main__.py
@@ -1,4 +1,5 @@
 import argparse
+import os
 import subprocess  # nosec
 import sys
 import warnings
@@ -6,7 +7,13 @@ import warnings
 from rich.progress import track
 
 from relint.config import load_config
-from relint.parse import lint_file, match_with_diff_changes, parse_diff, print_culprits
+from relint.parse import (
+    lint_file,
+    match_with_diff_changes,
+    parse_diff,
+    print_culprits,
+    print_github_actions_output,
+)
 
 
 def parse_args(args=None):
@@ -90,7 +97,11 @@ def main(args=None):
         changed_content = parse_diff(output)
         matches = match_with_diff_changes(changed_content, matches)
 
-    exit_code = print_culprits(matches, args)
+    GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true"
+    if GITHUB_ACTIONS:
+        exit_code = print_github_actions_output(matches, args)
+    else:
+        exit_code = print_culprits(matches, args)
     exit(exit_code)
 
 
diff --git a/relint/parse.py b/relint/parse.py
index 192f3b2..9c44462 100644
--- a/relint/parse.py
+++ b/relint/parse.py
@@ -93,6 +93,23 @@ def split_diff_content_by_filename(output: str) -> {str: str}:
     return content_by_filename
 
 
+def print_github_actions_output(matches, args):
+    exit_code = 0
+    for filename, test, match, line_number in matches:
+        exit_code = test.error if exit_code == 0 else exit_code
+        start_line_no = match.string[: match.start()].count("\n") + 1
+        end_line_no = match.string[: match.end()].count("\n") + 1
+        col = match.start() - match.string.rfind("\n", 0, match.start())
+        col_end = match.end() - match.string.rfind("\n", 0, match.end())
+
+        print(
+            f"::{'error' if test.error else 'warning'} file={filename},"
+            f"line={start_line_no},endLine={end_line_no},col={col},colEnd={col_end},"
+            f"title={test.name}::{test.hint}".replace("\n", "%0A")
+        )
+    return exit_code
+
+
 def print_culprits(matches, args):
     exit_code = 0
     messages = []


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index a833446..52919b6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,3 +9,9 @@ FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
 def fixture_dir():
     """Return the path to the fixture directory."""
     return FIXTURE_DIR
+
+
+@pytest.fixture(autouse=True)
+def env(monkeypatch):
+    """Remove GITHUB_ACTIONS the environment as it is part of our feature set."""
+    monkeypatch.setenv("GITHUB_ACTIONS", "false")
diff --git a/tests/test_main.py b/tests/test_main.py
index b8cfd80..850aa56 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -44,6 +44,25 @@ class TestMain:
         assert "❱ 1 # FIXME do something" in out
         assert exc_info.value.code == 1
 
+    def test_main_execution_with_error__github_workflow_output(
+        self, monkeypatch, capsys, tmpdir, fixture_dir
+    ):
+        monkeypatch.setenv("GITHUB_ACTIONS", "true")
+        with (fixture_dir / ".relint.yml").open() as fs:
+            config = fs.read()
+        tmpdir.join(".relint.yml").write(config)
+        tmpdir.join("dummy.py").write("# FIXME do something")
+        with tmpdir.as_cwd():
+            with pytest.raises(SystemExit) as exc_info:
+                main(["dummy.py"])
+
+        out, _ = capsys.readouterr()
+        assert (
+            "::error file=dummy.py,line=1,endLine=1,col=3,colEnd=8,title=No fixme (warning)::### This is a multiline hint%0AFix it right away!%0A%0AYou can use code blocks too, like Python:%0A%0A"
+            in out
+        )
+        assert exc_info.value.code == 1
+
     @pytest.mark.parametrize("args", [[], ["--summarize"]])
     def test_main_execution_without_hint(self, args, capsys, tmpdir, fixture_dir):
         with (fixture_dir / ".relint.yml").open() as fs: