Skip to content

Gitlab Report Format#135

Merged
Kilo59 merged 18 commits intomainfrom
gitlab
Mar 30, 2026
Merged

Gitlab Report Format#135
Kilo59 merged 18 commits intomainfrom
gitlab

Conversation

@Kilo59
Copy link
Copy Markdown
Owner

@Kilo59 Kilo59 commented Mar 30, 2026

Summary by Sourcery

Add a GitLab Code Quality JSON report formatter, integrate it into the formatter pipeline and CLI lifecycle, and document the formatter architecture and GitLab CI usage while tightening testing and project guidelines.

New Features:

  • Introduce a GitLab Code Quality report formatter and corresponding OutputFormat option, producing accumulated JSON issue arrays suitable for GitLab CI artifacts.
  • Extend the ResultFormatter protocol and implementations to support check_name and drift_key metadata for richer structured output, including JSON formatter enhancements.
  • Ensure the CLI check and pull commands always finalize formatters, enabling accumulating formatters to reliably flush their reports.

Enhancements:

  • Refine formatter selection via a match-based get_formatter implementation and document the streaming vs accumulating formatter architecture.
  • Clarify testing and contribution guidelines around dependency injection, mocking practices, and formatter lifecycle expectations.

Documentation:

  • Document GitLab CI codequality integration and formatter architecture in new reference guides, and update pre-commit configuration examples to the latest project version.

Tests:

  • Add comprehensive tests for GitlabFormatter behavior, formatter metadata propagation, and CLI lifecycle guarantees that finalize is called on success and error paths.

Chores:

  • Bump the project version to the next development release and update internal agent/reference documentation files to support formatter and CI features.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 94.40559% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.47%. Comparing base (9ae32e0) to head (20276ff).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/ruff_sync/core.py 89.18% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #135      +/-   ##
==========================================
+ Coverage   92.78%   93.47%   +0.68%     
==========================================
  Files           6        6              
  Lines         929      996      +67     
==========================================
+ Hits          862      931      +69     
+ Misses         67       65       -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Mar 30, 2026

Reviewer's Guide

Adds a new GitLab Code Quality formatter and supporting formatter architecture changes (metadata-aware error/warning signatures, finalize hook, and OutputFormat extension), wires finalize into CLI lifecycle, and updates tests and docs to cover GitLab reports, formatter behavior, and project conventions while bumping the project version.

Sequence diagram for check command with GitLab accumulating formatter and finalize hook

sequenceDiagram
    actor User
    participant CLI as cli.main
    participant Core as core.check
    participant Factory as get_formatter
    participant Fmt as GitlabFormatter
    participant GitlabReport as gl-code-quality-report.json

    User->>CLI: ruff-sync check --output-format gitlab
    CLI->>Factory: get_formatter(OutputFormat.GITLAB)
    Factory-->>CLI: GitlabFormatter instance
    CLI->>Core: await check(args, fmt=Fmt)

    activate Core
    Core->>Fmt: note("🔍 Checking Ruff sync status...")

    Core->>Core: resolve_target_path()
    Core->>Core: load and merge upstream configs

    alt configuration in sync
        Core->>Fmt: success("✅ Ruff configuration is in sync.")
        Core-->>CLI: return 0
    else configuration drift detected
        Core->>Fmt: error(message, file_path, logger, check_name, drift_key)
        opt --diff enabled
            Core->>Fmt: diff(diff_text)
        end
        Core-->>CLI: return 1
    end

    deactivate Core

    CLI->>Fmt: finalize()
    activate Fmt
    Fmt->>GitlabReport: print JSON array of GitlabIssue objects to stdout
    deactivate Fmt

    CLI-->>User: exit code (0 when in sync, 1 on drift)
Loading

Class diagram for updated formatter architecture with GitLab support

classDiagram
    class ResultFormatter {
        <<protocol>>
        +note(message: str) void
        +info(message: str, logger: logging.Logger|None) void
        +success(message: str) void
        +error(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +warning(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +debug(message: str, logger: logging.Logger|None) void
        +diff(diff_text: str) void
        +finalize() void
    }

    class TextFormatter {
        +note(message: str) void
        +info(message: str, logger: logging.Logger|None) void
        +success(message: str) void
        +error(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +warning(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +debug(message: str, logger: logging.Logger|None) void
        +diff(diff_text: str) void
        +finalize() void
    }

    class GithubFormatter {
        +note(message: str) void
        +info(message: str, logger: logging.Logger|None) void
        +success(message: str) void
        +error(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +warning(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +debug(message: str, logger: logging.Logger|None) void
        +diff(diff_text: str) void
        +finalize() void
        -_escape(value: str, is_property: bool) str
    }

    class JsonFormatter {
        +note(message: str) void
        +info(message: str, logger: logging.Logger|None) void
        +success(message: str) void
        +error(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +warning(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +debug(message: str, logger: logging.Logger|None) void
        +diff(diff_text: str) void
        +finalize() void
    }

    class GitlabLines {
        <<TypedDict>>
        +begin: int
    }

    class GitlabLocation {
        <<TypedDict>>
        +path: str
        +lines: GitlabLines
    }

    class GitlabIssue {
        <<TypedDict>>
        +description: str
        +check_name: str
        +fingerprint: str
        +severity: Literal
        +location: GitlabLocation
    }

    class GitlabFormatter {
        -_issues: list[GitlabIssue]
        +GitlabFormatter()
        +note(message: str) void
        +info(message: str, logger: logging.Logger|None) void
        +success(message: str) void
        +error(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +warning(message: str, file_path: pathlib.Path|None, logger: logging.Logger|None, check_name: str, drift_key: str|None) void
        +debug(message: str, logger: logging.Logger|None) void
        +diff(diff_text: str) void
        +finalize() void
        -_make_issue(description: str, check_name: str, severity: Literal, file_path: pathlib.Path|None, drift_key: str|None) GitlabIssue
        -_make_fingerprint(path: str, drift_key: str|None) str
    }

    class OutputFormat {
        <<enum>>
        TEXT
        JSON
        GITHUB
        GITLAB
    }

    class FormatterFactory {
        +get_formatter(output_format: OutputFormat) ResultFormatter
    }

    ResultFormatter <|.. TextFormatter
    ResultFormatter <|.. GithubFormatter
    ResultFormatter <|.. JsonFormatter
    ResultFormatter <|.. GitlabFormatter

    GitlabFormatter o--> GitlabIssue
    GitlabIssue o--> GitlabLocation
    GitlabLocation o--> GitlabLines

    OutputFormat ..> FormatterFactory
    FormatterFactory ..> TextFormatter
    FormatterFactory ..> GithubFormatter
    FormatterFactory ..> JsonFormatter
    FormatterFactory ..> GitlabFormatter
Loading

File-Level Changes

Change Details Files
Extend formatter protocol and implementations to carry check_name/drift_key metadata and support a finalize phase.
  • Add check_name and drift_key parameters with documentation to ResultFormatter.error/warning and propagate them through TextFormatter, GithubFormatter, and JsonFormatter.
  • Introduce a finalize() method on ResultFormatter with no-op implementations for streaming formatters (Text, GitHub, JSON).
  • Enhance JsonFormatter to always include check_name and optionally drift_key in emitted JSON records, keeping newline-delimited streaming behavior unchanged.
src/ruff_sync/formatters.py
tests/test_formatters.py
Introduce a GitlabFormatter that accumulates issues and emits a GitLab Code Quality JSON array with stable fingerprints.
  • Define GitlabIssue/Location/Lines TypedDicts and implement GitlabFormatter that logs diagnostics, accumulates issues on error/warning, and prints a single JSON array in finalize().
  • Normalize file paths to be relative to CWD, default file_path to pyproject.toml when missing, and map error/warning to major/minor severities respectively.
  • Implement deterministic MD5-based fingerprints derived from path and drift_key, ensuring distinct fingerprints per drift_key and stability across runs, and ignore diff()/non-error diagnostics in the structured report.
src/ruff_sync/formatters.py
tests/test_formatters.py
.agents/gitlab-reports.md
Wire finalize() into the CLI lifecycle so formatters are always flushed, and expose the new GitLab output format via OutputFormat and factory.
  • Change check() to wrap its logic in a try/finally that always calls fmt.finalize(), preserving existing exit-code behavior and diff printing while using the formatter interface.
  • Change pull() similarly to always call finalize() while preserving init/save/pre-commit behavior.
  • Extend OutputFormat enum with GITLAB and refactor get_formatter to use a match statement that returns Text, JSON, GitHub, or GitlabFormatter as appropriate; update tests to assert the new mapping and drop the previous invalid-format fallback.
src/ruff_sync/core.py
src/ruff_sync/constants.py
src/ruff_sync/formatters.py
tests/test_formatters.py
Refine tests to cover formatter lifecycle, metadata propagation, and GitLab report semantics.
  • Adjust the formatter fixture to represent only streaming formatters and add a test asserting finalize() is a no-op for them (no stdout/stderr output).
  • Add comprehensive TestGitlabFormatter cases for default/absolute path handling, empty output, severity mapping, fingerprint behavior, accumulation vs. ignored methods, and logger delegation (including a custom logger spy).
  • Add JsonFormatter-specific tests verifying that check_name is always present, and introduce SpyFormatter/TestCLILifecycle tests that monkeypatch get_formatter to confirm core.check() calls finalize() on both success and error paths.
tests/test_formatters.py
Update internal documentation and project metadata to reflect the new formatter architecture, GitLab integration, and testing conventions.
  • Document formatter architecture (streaming vs accumulating, finalize contract, fingerprint strategy) in a new .agents/formatters-architecture.md and capture detailed GitLab Code Quality requirements and CI configuration in .agents/gitlab-reports.md.
  • Enhance testing and contributor guidance docs to forbid unittest.mock, encourage dependency injection and IO-layer fakes, and reiterate main-entry-point and style expectations.
  • Update CI/pre-commit integration docs to reference ruff-sync v0.1.3, add a GitHub issues skill document, capture Issue Richer CI output: JSON, SARIF, and GitHub Actions annotations for check command #102 context, and bump the project version to 0.1.4.dev0 in pyproject.toml.
.agents/TESTING.md
AGENTS.md
docs/pre-commit.md
.agents/skills/ruff-sync-usage/references/ci-integration.md
.agents/gitlab-reports.md
.agents/formatters-architecture.md
.agents/skills/gh-issues/SKILL.md
ISSUE_102_CONTEXT.md
pyproject.toml
uv.lock

Possibly linked issues

  • #: PR adds GitlabFormatter, OutputFormat.GITLAB, finalize wiring, tests, and docs to deliver GitLab codequality support

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Kilo59 Kilo59 marked this pull request as ready for review March 30, 2026 02:45
Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In GitlabFormatter._make_issue, location.path is documented as needing to be relative to the repo root but currently uses str(file_path) directly, which may be absolute; consider normalizing this to a relative path (e.g., via Path.cwd() or a passed-in repo root) to avoid GitLab failing to link annotations.
  • The JsonFormatter.error/warning methods now accept check_name but ignore it in the emitted JSON; if check_name is part of the structured metadata contract, consider adding it to the JSON payload for consistency with other structured outputs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `GitlabFormatter._make_issue`, `location.path` is documented as needing to be relative to the repo root but currently uses `str(file_path)` directly, which may be absolute; consider normalizing this to a relative path (e.g., via `Path.cwd()` or a passed-in repo root) to avoid GitLab failing to link annotations.
- The `JsonFormatter.error`/`warning` methods now accept `check_name` but ignore it in the emitted JSON; if `check_name` is part of the structured metadata contract, consider adding it to the JSON payload for consistency with other structured outputs.

## Individual Comments

### Comment 1
<location path="src/ruff_sync/formatters.py" line_range="437" />
<code_context>
+        file_path: pathlib.Path | None,
+        fingerprint: str | None,
+    ) -> dict[str, Any]:
+        path = str(file_path) if file_path else "pyproject.toml"
+        fp = fingerprint or self._auto_fingerprint(description, path)
+        return {
</code_context>
<issue_to_address>
**issue (bug_risk):** Normalize GitLab issue paths to be relative to repo root to match GitLab expectations

`location.path` in the Code Quality spec must be relative to the project root, but `file_path` here can be absolute (e.g. when `pull()` passes `_source_toml_path`). That would violate the spec and may cause GitLab to misplace or ignore annotations. Please normalize to a project-root-relative path (e.g. `Path.cwd()`-relative as in `check()`) or ensure all callers pass a relative path.
</issue_to_address>

### Comment 2
<location path="tests/test_formatters.py" line_range="157" />
<code_context>
             assert data["file"] == "f.py"


</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for JsonFormatter’s new drift_key (and optionally check_name) metadata fields.

The formatter now supports `drift_key` (and accepts `check_name`), but the tests only assert on `file`. Please extend `test_metadata_propagation` (or add a sibling test) to invoke the formatter with both `file_path` and `drift_key` set, and assert that the JSON includes `drift_key` with the expected value. If `check_name` is meant to appear in the JSON, add an assertion for that as well so schema changes are caught by tests.

Suggested implementation:

```python
            assert data["file"] == "f.py"
            assert data["drift_key"] == "drift-123"
            assert data.get("check_name") == "example-check"

```

To fully implement the suggestion, also ensure that the test invoking the `JsonFormatter` passes the matching metadata values:

1. Wherever `JsonFormatter` (or the helper that exercises it in `test_metadata_propagation`) is called, update the call to include `drift_key="drift-123"` and `check_name="example-check"` alongside `file_path="f.py"`. For example, if the current call looks like:

   ```python
   formatter = JsonFormatter(stream=io.StringIO())
   formatter.format(result, file_path="f.py")
   ```

   adjust it to:

   ```python
   formatter = JsonFormatter(stream=io.StringIO())
   formatter.format(
       result,
       file_path="f.py",
       drift_key="drift-123",
       check_name="example-check",
   )
   ```

2. If `test_metadata_propagation` also exercises non-JSON formatters, either:
   * restrict the new `drift_key` / `check_name` assertions to the JSON case (e.g., by only running them when `formatter` is a `JsonFormatter`), or
   * move this logic into a JSON-specific sibling test such as `test_json_metadata_propagation_includes_drift_key_and_check_name`.
</issue_to_address>

### Comment 3
<location path="tests/test_formatters.py" line_range="160" />
<code_context>
             assert data["file"] == "f.py"


+class TestGitlabFormatter:
+    """Tests for GitlabFormatter (GitLab Code Quality report format)."""
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for the GitLab formatter’s default path behaviour when file_path is None.

Currently, all tests pass a file_path. Please also cover the config-level case where `file_path` is `None` and `GitlabFormatter._make_issue` should set `location.path` to `"pyproject.toml"` (e.g. `fmt.error("drift", file_path=None)` and assert `issue["location"]["path"] == "pyproject.toml"`). This will lock in the default-path behavior the formatter depends on.

Suggested implementation:

```python
class TestGitlabFormatter:
    """Tests for GitlabFormatter (GitLab Code Quality report format)."""

    def test_default_path_when_file_path_is_none(self) -> None:
        """When file_path is None, GitlabFormatter should default to 'pyproject.toml'."""
        output = io.StringIO()
        fmt = GitlabFormatter(output)

        # Trigger an issue without an explicit file_path: formatter should fall back
        # to its default config file path ("pyproject.toml").
        fmt.error("drift", file_path=None)
        fmt.finalize()

        output.seek(0)
        issues = json.loads(output.getvalue() or "[]")

        assert issues, "Expected at least one issue to be emitted"
        assert issues[0]["location"]["path"] == "pyproject.toml"

```

1. Ensure `io` and `json` are imported at the top of `tests/test_formatters.py`:
   - If not already present, add `import io` and `import json` alongside the other imports.
2. If `GitlabFormatter` requires additional constructor arguments (e.g. config/path), update the instantiation in the test accordingly so that it uses the same pattern as the other GitLab formatter tests in this file.
3. If `fmt.error` has a different signature (e.g. requires a message or other parameters), adjust the call in the test to match the existing usage elsewhere in the test suite, keeping `file_path=None` to exercise the default-path behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Kilo59 Kilo59 self-assigned this Mar 30, 2026
@Kilo59
Copy link
Copy Markdown
Owner Author

Kilo59 commented Mar 30, 2026

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_formatters.py" line_range="205-207" />
<code_context>
+        issues = json.loads(capsys.readouterr().out)
+        assert issues == []
+
+    def test_error_produces_major_severity(self, capsys: pytest.CaptureFixture[str]) -> None:
+        """error() must accumulate a major-severity issue."""
+        fmt = GitlabFormatter()
+        fmt.error("drift found", file_path=pathlib.Path("pyproject.toml"))
+        fmt.finalize()
+        issues = json.loads(capsys.readouterr().out)
+        assert len(issues) == 1
+        assert issues[0]["severity"] == "major"
+        assert issues[0]["location"]["path"] == "pyproject.toml"
+        assert issues[0]["location"]["lines"]["begin"] == 1
+        assert "fingerprint" in issues[0]
+        assert "description" in issues[0]
+        assert "check_name" in issues[0]
+
+    def test_warning_produces_minor_severity(self, capsys: pytest.CaptureFixture[str]) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Assert that GitLab formatter uses the expected default check_name when not overridden

Since `check_name` is part of the external contract, it’d be helpful to also assert its exact value (e.g. `"ruff-sync/config-drift"`). That way the test will catch accidental changes to the default identifier that could affect downstream consumers.

```suggestion
        assert "fingerprint" in issues[0]
        assert "description" in issues[0]
        # Default check_name is part of the external contract; assert exact value
        assert issues[0]["check_name"] == "ruff-sync/config-drift"
```
</issue_to_address>

### Comment 2
<location path="tests/test_formatters.py" line_range="27-32" />
<code_context>
+# ---------------------------------------------------------------------------
+

 @pytest.fixture(params=[TextFormatter, GithubFormatter, JsonFormatter])
 def formatter(request: pytest.FixtureRequest) -> ResultFormatter:
-    """Fixture providing instances of all registered formatters."""
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding tests that verify finalize() is invoked in CLI workflows even on early returns

Current coverage only ensures `finalize()` exists and is a no-op for streaming formatters, and that GitLab’s accumulating formatter uses it in normal flows. Since `check` and `pull` now always wrap formatter usage in `try/finally`, please add a CLI-focused test that injects a spy formatter via `get_formatter` and asserts `finalize()` is called on both the success path and early-return error paths (e.g., missing config). This will lock in the guarantee that finalization always runs, which is particularly important for accumulating formatters like GitLab.

Suggested implementation:

```python
@pytest.fixture(params=[TextFormatter, GithubFormatter, JsonFormatter])
def formatter(request: pytest.FixtureRequest) -> ResultFormatter:
    """Fixture providing instances of all streaming formatters."""
    formatter_cls: type[ResultFormatter] = request.param
    return formatter_cls()


class SpyFormatter(TextFormatter):
    """Formatter spy that records whether finalize() was called."""

    def __init__(self, *args, **kwargs) -> None:  # type: ignore[no-untyped-def]
        super().__init__(*args, **kwargs)
        self.finalized_called = False

    def finalize(self) -> None:
        self.finalized_called = True


@pytest.mark.usefixtures("tmp_path")
def test_cli_check_calls_finalize_on_success(monkeypatch: pytest.MonkeyPatch, tmp_path: "Path") -> None:
    """Ensure finalize() is called in the CLI check happy path."""
    spy = SpyFormatter()

    def fake_get_formatter(*args, **kwargs):  # type: ignore[no-untyped-def]
        return spy

    # Route all formatter creation in the check CLI through our spy.
    monkeypatch.setattr(
        "ruff_sync.formatters.get_formatter",
        fake_get_formatter,
        raising=True,
    )

    # Import locally to avoid import cycles at module import time.
    from ruff_sync.cli import check as check_cli  # type: ignore[import]

    # Arrange: create a minimal valid configuration so that the command succeeds
    config_path = tmp_path / "pyproject.toml"
    config_path.write_text(
        "[tool.ruff-sync]\n"
        "upstream = \"origin/main\"\n",
        encoding="utf-8",
    )

    # Act: run the CLI check command in a way that exercises the try/finally block.
    check_cli.main(["--config", str(config_path)])

    # Assert: finalize() has been invoked on the spy formatter.
    assert spy.finalized_called is True


@pytest.mark.usefixtures("tmp_path")
def test_cli_check_calls_finalize_on_early_return(monkeypatch: pytest.MonkeyPatch, tmp_path: "Path") -> None:
    """Ensure finalize() is called even when check exits early (e.g. missing/invalid config)."""
    spy = SpyFormatter()

    def fake_get_formatter(*args, **kwargs):  # type: ignore[no-untyped-def]
        return spy

    monkeypatch.setattr(
        "ruff_sync.formatters.get_formatter",
        fake_get_formatter,
        raising=True,
    )

    from ruff_sync.cli import check as check_cli  # type: ignore[import]

    # Arrange: point to a config path that does not exist to trigger an early-return error path.
    missing_config_path = tmp_path / "does-not-exist.toml"

    # Act: run the CLI, expecting it to exit non‑zero (e.g. via SystemExit).
    with pytest.raises(SystemExit):
        check_cli.main(["--config", str(missing_config_path)])

    # Assert: even on early exit, finalize() was still called.
    assert spy.finalized_called is True

```

These edits assume:

1. There is a `ruff_sync.cli.check` module exposing a `main(argv: list[str])` function that:
   - Uses `ruff_sync.formatters.get_formatter(...)` to construct the formatter.
   - Wraps formatter usage in a `try/finally` block that always calls `formatter.finalize()`.

2. The CLI accepts a `--config PATH` option and:
   - Succeeds when the pointed `pyproject.toml` exists and is valid.
   - Raises `SystemExit` (or otherwise terminates) when the config is missing/invalid.

If your actual CLI surface differs, you will need to:

1. Adjust the imports in both tests:
   - Replace `from ruff_sync.cli import check as check_cli` with the correct module / symbol.
   - If the entrypoint is named differently (e.g. `cli`, `run`, `check`), update `check_cli.main([...])` accordingly.

2. Adjust the monkeypatch target if `get_formatter` is imported into the CLI module rather than accessed via `ruff_sync.formatters`:
   - For example, if `check.py` does `from ruff_sync.formatters import get_formatter`, change:
     - `"ruff_sync.formatters.get_formatter"``"ruff_sync.cli.check.get_formatter"`.

3. Adapt the arguments to the CLI invocation if `--config` is not the correct flag or the config mechanism differs:
   - E.g. `["--config-file", str(config_path)]` or other appropriate flags.

4. If `SystemExit` is not raised on missing config but an error code is returned instead:
   - Replace the `with pytest.raises(SystemExit): ...` block with a direct call and assertion on the return code.

5. Ensure `Path` is imported or available in `tests/test_formatters.py`:
   - If not already present at the top of the file, add:
     - `from pathlib import Path`.

With these small adjustments to align with your real CLI API, the tests will guarantee that `finalize()` is invoked in both the success and early‑error code paths of the CLI, protecting accumulating formatters like GitLab.
</issue_to_address>

### Comment 3
<location path=".agents/formatters-architecture.md" line_range="51" />
<code_context>
+| `warning(message, file_path, logger, check_name, drift_key)` | Non-fatal issue (e.g. stale pre-commit hook) | See §Semantic Fields |
+| `debug(message, logger)` | Verbose internal state | Streaming |
+| `diff(diff_text)` | Unified diff between upstream and local | Intentionally ignored by structured formatters |
+| `finalize()` | **Always called in a `try…finally` by `cli.py`** | No-op for streaming; writes report for accumulating |
+
+### Semantic Fields on `error()` and `warning()`
</code_context>
<issue_to_address>
**issue:** Clarify `finalize()` contract to avoid conflict with `.agents/gitlab-reports.md` guidance.

This description says `finalize()` is always on the `ResultFormatter` protocol and always called from `cli.py`, whereas `.agents/gitlab-reports.md` still allows for formatters without `finalize()` (using `hasattr`/`isinstance`). Please align these so there’s a single, clear contract for `finalize()` (for example, document that it is required and older optional patterns are deprecated), to avoid confusing formatter implementers.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Kilo59
Copy link
Copy Markdown
Owner Author

Kilo59 commented Mar 30, 2026

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In GitlabFormatter._make_fingerprint, the fingerprint is derived only from path and drift_key; if you later introduce multiple check types at the same path/key (e.g., different check_name values), they will collide, so consider including check_name (and possibly upstream identity) in the fingerprint input to keep issues distinct.
  • For GitlabFormatter._make_issue, when file_path is absolute but not under cwd, you fall back to the raw absolute string even though GitLab expects paths relative to the repo root; it may be safer to normalize such cases (e.g., always emit just the filename or a best-effort relative path) to avoid losing annotations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `GitlabFormatter._make_fingerprint`, the fingerprint is derived only from `path` and `drift_key`; if you later introduce multiple check types at the same path/key (e.g., different `check_name` values), they will collide, so consider including `check_name` (and possibly upstream identity) in the fingerprint input to keep issues distinct.
- For `GitlabFormatter._make_issue`, when `file_path` is absolute but not under `cwd`, you fall back to the raw absolute string even though GitLab expects paths relative to the repo root; it may be safer to normalize such cases (e.g., always emit just the filename or a best-effort relative path) to avoid losing annotations.

## Individual Comments

### Comment 1
<location path="src/ruff_sync/formatters.py" line_range="450-453" />
<code_context>
+    ) -> dict[str, Any]:
+        path = str(file_path) if file_path else "pyproject.toml"
+        fp = fingerprint or self._auto_fingerprint(description, path)
+        return {
+            "description": description,
+            "check_name": check_name,
</code_context>
<issue_to_address>
**issue (bug_risk):** GitLab issue fingerprints only use path and drift key, which risks collisions between distinct issues.

Because the fingerprint only uses `path` and `drift_key`, distinct issues with the same pair (for example, different `check_name`s or messages) will be merged by GitLab, breaking introduced/resolved tracking. Please include `check_name` (and optionally a normalized description) in the fingerprint input so different rules on the same key generate distinct, deterministic fingerprints (for example, `raw = f"ruff-sync:{check_name}:{path}:{drift_key or ''}"`).
</issue_to_address>

### Comment 2
<location path="tests/test_formatters.py" line_range="226-235" />
<code_context>
+    def test_drift_key_produces_distinct_fingerprints(
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a GitLab fingerprint test for cases without drift_key

Current tests cover distinct fingerprints for different `drift_key` values and stability when both `path` and `drift_key` match, but not the fallback behavior when `drift_key` is omitted. Since the implementation then uses `path` only, please add a test that:

- calls `error()` twice with the same `file_path` and `drift_key=None`, asserting equal fingerprints
- optionally calls `error()` with different `file_path` values (no `drift_key`), asserting different fingerprints

This will lock in behavior for tools that don’t pass a `drift_key` while keeping fingerprints stable and distinct.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown

@SourceryAI SourceryAI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_formatters.py" line_range="164-173" />
<code_context>
+class TestGitlabFormatter:
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test for absolute paths that are *not* under the current working directory.

Right now you only test absolute paths under `cwd` that normalize to relative paths. Please also cover the case where `file_path.is_absolute()` is true but `relative_to(pathlib.Path.cwd())` raises `ValueError` (e.g. different drive or outside the repo), and assert that `location.path` matches the intended fallback (likely the original string). This will lock in the expected behavior for that branch.

Suggested implementation:

```python
class TestGitlabFormatter:
    """Tests for GitlabFormatter (GitLab Code Quality report format)."""

    def test_default_path_when_file_path_is_none(self, capsys: pytest.CaptureFixture[str]) -> None:
        """When file_path is None, GitlabFormatter should default to 'pyproject.toml'."""
        fmt = GitlabFormatter()
        fmt.error("drift", file_path=None)
        fmt.finalize()
        issues = json.loads(capsys.readouterr().out)
        assert len(issues) == 1
        assert issues[0]["location"]["path"] == "pyproject.toml"

    def test_absolute_path_outside_cwd_falls_back_to_original_string(
        self,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Absolute paths not under cwd should be preserved as-is in location.path."""
        fmt = GitlabFormatter()

        # Construct an absolute path that is not a sub-path of the current working directory
        outside_path = (pathlib.Path.cwd().parent / "outside_project" / "some_file.py").resolve()

        fmt.error("drift", file_path=outside_path)
        fmt.finalize()

        issues = json.loads(capsys.readouterr().out)
        assert len(issues) == 1
        # When relative_to(Path.cwd()) raises ValueError, GitlabFormatter should fall back
        # to using the original absolute path string.
        assert issues[0]["location"]["path"] == str(outside_path)

```

1. Ensure `pathlib` is imported at the top of `tests/test_formatters.py`:
   - If not already present, add `import pathlib` alongside the other imports.
2. This test assumes that `GitlabFormatter` implements the branch where an absolute `file_path`
   that is not under `Path.cwd()` is preserved as the original string when `relative_to` fails.
   If the current implementation differs, adjust the expected assertion to match the actual fallback behavior.
</issue_to_address>

Hi @Kilo59! 👋

Thanks for trying out Sourcery by commenting with @sourcery-ai review! 🚀

Install the sourcery-ai bot to get automatic code reviews on every pull request ✨

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Kilo59 Kilo59 enabled auto-merge (squash) March 30, 2026 12:20
@Kilo59 Kilo59 added the enhancement New feature or request label Mar 30, 2026
@Kilo59 Kilo59 added the AI Anything AI related label Mar 30, 2026
@github-actions
Copy link
Copy Markdown

╒═══════════════╤══════════════╤═════════════════╤════════════╤═════════════════╕
│ File          │ Cyclomatic   │ Maintainabili   │ Unique     │ Lines of Code   │
│               │ Complexity   │ ty Index        │ Operands   │                 │
╞═══════════════╪══════════════╪═════════════════╪════════════╪═════════════════╡
│ tests/test_fo │ 27 -> 64     │ 51.5208 ->      │ 1 -> 1     │ 154 -> 529      │
│ rmatters.py   │              │ 22.3685         │            │                 │
╘═══════════════╧══════════════╧═════════════════╧════════════╧═════════════════╛

@Kilo59 Kilo59 merged commit 47081f6 into main Mar 30, 2026
34 of 37 checks passed
@Kilo59 Kilo59 deleted the gitlab branch March 30, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Anything AI related enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitLab CI codequality report support (GitlabFormatter + --output-format gitlab)

2 participants