codeowners-coverage 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/PKG-INFO +11 -4
  2. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/README.md +8 -2
  3. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/pyproject.toml +4 -2
  4. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/__init__.py +1 -1
  5. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/cli.py +52 -25
  6. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/ollama_matcher.py +10 -2
  7. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/PKG-INFO +11 -4
  8. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/SOURCES.txt +1 -0
  9. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/requires.txt +3 -1
  10. codeowners_coverage-0.3.1/tests/test_cli.py +228 -0
  11. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_ollama_matcher.py +8 -4
  12. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/setup.cfg +0 -0
  13. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/__main__.py +0 -0
  14. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/checker.py +0 -0
  15. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/config.py +0 -0
  16. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/directory_consolidator.py +0 -0
  17. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/git_analyzer.py +0 -0
  18. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/github_client.py +0 -0
  19. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/matcher.py +0 -0
  20. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/suggest_cache.py +0 -0
  21. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage/suggester.py +0 -0
  22. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/dependency_links.txt +0 -0
  23. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/entry_points.txt +0 -0
  24. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/src/codeowners_coverage.egg-info/top_level.txt +0 -0
  25. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_checker.py +0 -0
  26. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_config.py +0 -0
  27. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_directory_consolidator.py +0 -0
  28. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_git_analyzer.py +0 -0
  29. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_github_client.py +0 -0
  30. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_matcher.py +0 -0
  31. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_suggest_cache.py +0 -0
  32. {codeowners_coverage-0.2.1 → codeowners_coverage-0.3.1}/tests/test_suggester.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeowners-coverage
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Measure and enforce CODEOWNERS coverage
5
5
  Author-email: Sentry Team <hello@sentry.io>
6
6
  License: Apache-2.0
@@ -10,7 +10,8 @@ Requires-Dist: pathspec>=0.12.0
10
10
  Requires-Dist: PyYAML>=6.0
11
11
  Requires-Dist: click>=8.0
12
12
  Requires-Dist: requests>=2.31.0
13
- Requires-Dist: ollama>=0.3.0
13
+ Provides-Extra: suggest
14
+ Requires-Dist: ollama>=0.3.0; extra == "suggest"
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest>=7.0; extra == "dev"
16
17
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -57,7 +58,11 @@ just ollama-setup # Downloads and configures Ollama model
57
58
  ## Installation
58
59
 
59
60
  ```bash
61
+ # Core (check + baseline commands)
60
62
  pip install codeowners-coverage
63
+
64
+ # With AI-powered suggestions (requires Ollama)
65
+ pip install codeowners-coverage[suggest]
61
66
  ```
62
67
 
63
68
  ## Usage
@@ -76,6 +81,8 @@ codeowners-coverage baseline
76
81
 
77
82
  ### Suggest CODEOWNERS entries (AI-powered)
78
83
 
84
+ > Requires the `[suggest]` extra: `pip install codeowners-coverage[suggest]`
85
+
79
86
  Use local LLM to intelligently suggest team ownership based on git history.
80
87
 
81
88
  **GitHub Token Setup:**
@@ -251,8 +258,8 @@ just release
251
258
  ### Manual commands
252
259
 
253
260
  ```bash
254
- # Install in development mode
255
- uv pip install -e ".[dev]"
261
+ # Install in development mode (all features)
262
+ uv pip install -e ".[dev,suggest]"
256
263
 
257
264
  # Run tests
258
265
  pytest tests/ -v
@@ -36,7 +36,11 @@ just ollama-setup # Downloads and configures Ollama model
36
36
  ## Installation
37
37
 
38
38
  ```bash
39
+ # Core (check + baseline commands)
39
40
  pip install codeowners-coverage
41
+
42
+ # With AI-powered suggestions (requires Ollama)
43
+ pip install codeowners-coverage[suggest]
40
44
  ```
41
45
 
42
46
  ## Usage
@@ -55,6 +59,8 @@ codeowners-coverage baseline
55
59
 
56
60
  ### Suggest CODEOWNERS entries (AI-powered)
57
61
 
62
+ > Requires the `[suggest]` extra: `pip install codeowners-coverage[suggest]`
63
+
58
64
  Use local LLM to intelligently suggest team ownership based on git history.
59
65
 
60
66
  **GitHub Token Setup:**
@@ -230,8 +236,8 @@ just release
230
236
  ### Manual commands
231
237
 
232
238
  ```bash
233
- # Install in development mode
234
- uv pip install -e ".[dev]"
239
+ # Install in development mode (all features)
240
+ uv pip install -e ".[dev,suggest]"
235
241
 
236
242
  # Run tests
237
243
  pytest tests/ -v
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codeowners-coverage"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Measure and enforce CODEOWNERS coverage"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -17,13 +17,15 @@ dependencies = [
17
17
  "PyYAML>=6.0", # Config file parsing
18
18
  "click>=8.0", # CLI framework
19
19
  "requests>=2.31.0", # For GitHub API
20
- "ollama>=0.3.0", # For local LLM integration
21
20
  ]
22
21
 
23
22
  [project.scripts]
24
23
  codeowners-coverage = "codeowners_coverage.__main__:main"
25
24
 
26
25
  [project.optional-dependencies]
26
+ suggest = [
27
+ "ollama>=0.3.0", # For local LLM integration (suggest command)
28
+ ]
27
29
  dev = [
28
30
  "pytest>=7.0",
29
31
  "pytest-cov>=4.0",
@@ -1,6 +1,6 @@
1
1
  """CODEOWNERS coverage checking tool."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.1"
4
4
 
5
5
  from .checker import CoverageChecker
6
6
  from .config import Config
@@ -6,21 +6,18 @@ import json
6
6
  import sys
7
7
  from datetime import datetime
8
8
  from pathlib import Path
9
- from typing import Any, Dict, List, Tuple, cast
9
+ from typing import TYPE_CHECKING, Any, Dict, List, Tuple, cast
10
10
 
11
11
  import click
12
12
 
13
- from .checker import CoverageChecker
14
- from .config import Config
15
- from .directory_consolidator import DirectoryConsolidator
16
- from .git_analyzer import GitHistoryAnalyzer
17
- from .github_client import GitHubClient
18
- from .matcher import CodeOwnersPatternMatcher
19
- from .ollama_matcher import OllamaLLMMatcher, TeamSuggestion
20
- from .suggest_cache import SuggestCache
21
- from .suggester import OwnershipSuggester, SuggestionResult
22
13
  from . import __version__
23
14
 
15
+ if TYPE_CHECKING:
16
+ from .config import Config
17
+ from .ollama_matcher import TeamSuggestion
18
+ from .suggest_cache import SuggestCache
19
+ from .suggester import SuggestionResult
20
+
24
21
 
25
22
  @click.group()
26
23
  @click.version_option(version=__version__)
@@ -33,7 +30,12 @@ def cli() -> None:
33
30
  @click.option("--json", "output_json", is_flag=True, help="Output JSON format")
34
31
  @click.option("--files", multiple=True, help="Specific files to check")
35
32
  @click.option("--config", default=".codeowners-config.yml", help="Config file path")
36
- def check(output_json: bool, files: Tuple[str, ...], config: str) -> None:
33
+ @click.option(
34
+ "--allow-dirty-baseline",
35
+ is_flag=True,
36
+ help="Don't fail when baseline contains entries that are now covered",
37
+ )
38
+ def check(output_json: bool, files: Tuple[str, ...], config: str, allow_dirty_baseline: bool) -> None:
37
39
  """
38
40
  Check CODEOWNERS coverage.
39
41
 
@@ -42,30 +44,30 @@ def check(output_json: bool, files: Tuple[str, ...], config: str) -> None:
42
44
  New uncovered files will cause the check to fail.
43
45
  """
44
46
  try:
47
+ from .checker import CoverageChecker
48
+ from .config import Config
49
+
45
50
  cfg = Config.load(config)
46
51
  checker = CoverageChecker(cfg)
47
52
 
48
53
  file_list: List[str] | None = list(files) if files else None
49
54
  result = checker.check_coverage(file_list)
50
55
 
51
- if output_json:
52
- click.echo(json.dumps(result, indent=2))
53
- else:
54
- _print_human_readable_result(result)
55
-
56
- # Exit with error if there are new uncovered files
57
- if result["uncovered_files"]:
58
- sys.exit(1)
59
-
60
- # Exit with code 2 if baseline can be reduced (positive signal)
61
- # Check if any baseline entries (literal paths or glob patterns)
62
- # no longer match any uncovered files
56
+ # Calculate unused baseline entries early
63
57
  loaded_baseline = checker._load_baseline()
64
58
  baseline_matched = cast(List[str], result["baseline_files"])
65
59
  unused_entries = loaded_baseline.get_unused_entries(baseline_matched)
66
60
 
67
- if unused_entries:
68
- if not output_json:
61
+ # Output results
62
+ if output_json:
63
+ json_output = result.copy()
64
+ json_output["unused_baseline_entries"] = unused_entries
65
+ click.echo(json.dumps(json_output, indent=2))
66
+ else:
67
+ _print_human_readable_result(result)
68
+
69
+ # Print unused entries message in human-readable mode
70
+ if unused_entries:
69
71
  click.echo(f"\n🎉 Great news! {len(unused_entries)} baseline entries can be removed:")
70
72
  for entry in unused_entries[:10]:
71
73
  click.echo(f" - {entry}")
@@ -73,6 +75,14 @@ def check(output_json: bool, files: Tuple[str, ...], config: str) -> None:
73
75
  click.echo(f" ... and {len(unused_entries) - 10} more")
74
76
  click.echo("\nThese entries now have CODEOWNERS coverage! Update the baseline:")
75
77
  click.echo(" codeowners-coverage baseline")
78
+
79
+ # Exit with error if there are new uncovered files
80
+ if result["uncovered_files"]:
81
+ sys.exit(1)
82
+
83
+ # Exit with code 2 if baseline can be reduced (positive signal)
84
+ # Only exit with code 2 if baseline is dirty AND flag is not set
85
+ if unused_entries and not allow_dirty_baseline:
76
86
  sys.exit(2)
77
87
 
78
88
  except FileNotFoundError as e:
@@ -95,6 +105,9 @@ def baseline(config: str, files: Tuple[str, ...]) -> None:
95
105
  allowing existing gaps.
96
106
  """
97
107
  try:
108
+ from .checker import CoverageChecker
109
+ from .config import Config
110
+
98
111
  cfg = Config.load(config)
99
112
  checker = CoverageChecker(cfg)
100
113
 
@@ -198,6 +211,15 @@ def suggest(
198
211
  Requires Ollama to be installed and running.
199
212
  """
200
213
  try:
214
+ from .checker import CoverageChecker
215
+ from .config import Config
216
+ from .directory_consolidator import DirectoryConsolidator
217
+ from .git_analyzer import GitHistoryAnalyzer
218
+ from .github_client import GitHubClient
219
+ from .matcher import CodeOwnersPatternMatcher
220
+ from .ollama_matcher import OllamaLLMMatcher
221
+ from .suggester import OwnershipSuggester
222
+
201
223
  # Load config
202
224
  cfg = Config.load(config)
203
225
 
@@ -308,6 +330,9 @@ def suggest(
308
330
  f"✓ Connected to Ollama "
309
331
  f"(model: {cfg.ollama_model})"
310
332
  )
333
+ except ImportError as e:
334
+ click.echo(f"❌ {e}", err=True)
335
+ sys.exit(1)
311
336
  except Exception as e:
312
337
  click.echo(
313
338
  f"❌ Failed to connect to Ollama: {e}",
@@ -397,6 +422,8 @@ def _setup_cache(
397
422
  files: List[str],
398
423
  ) -> SuggestCache | None:
399
424
  """Load or initialize the suggest cache."""
425
+ from .suggest_cache import SuggestCache
426
+
400
427
  if no_cache:
401
428
  click.echo(" Cache: disabled (--no-cache)")
402
429
  return None
@@ -6,7 +6,10 @@ import json
6
6
  from dataclasses import dataclass
7
7
  from typing import Dict, List, Tuple
8
8
 
9
- import ollama
9
+ try:
10
+ import ollama
11
+ except ImportError:
12
+ ollama = None # type: ignore[assignment]
10
13
 
11
14
 
12
15
  @dataclass
@@ -38,10 +41,15 @@ class OllamaLLMMatcher:
38
41
  Raises:
39
42
  Exception: If unable to connect to Ollama
40
43
  """
44
+ if ollama is None:
45
+ raise ImportError(
46
+ "The 'ollama' package is required for the suggest command. "
47
+ "Install it with: pip install codeowners-coverage[suggest]"
48
+ )
49
+
41
50
  self.model = model
42
51
  self.base_url = base_url
43
52
 
44
- # Verify Ollama is available
45
53
  try:
46
54
  ollama.list()
47
55
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeowners-coverage
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Measure and enforce CODEOWNERS coverage
5
5
  Author-email: Sentry Team <hello@sentry.io>
6
6
  License: Apache-2.0
@@ -10,7 +10,8 @@ Requires-Dist: pathspec>=0.12.0
10
10
  Requires-Dist: PyYAML>=6.0
11
11
  Requires-Dist: click>=8.0
12
12
  Requires-Dist: requests>=2.31.0
13
- Requires-Dist: ollama>=0.3.0
13
+ Provides-Extra: suggest
14
+ Requires-Dist: ollama>=0.3.0; extra == "suggest"
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest>=7.0; extra == "dev"
16
17
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -57,7 +58,11 @@ just ollama-setup # Downloads and configures Ollama model
57
58
  ## Installation
58
59
 
59
60
  ```bash
61
+ # Core (check + baseline commands)
60
62
  pip install codeowners-coverage
63
+
64
+ # With AI-powered suggestions (requires Ollama)
65
+ pip install codeowners-coverage[suggest]
61
66
  ```
62
67
 
63
68
  ## Usage
@@ -76,6 +81,8 @@ codeowners-coverage baseline
76
81
 
77
82
  ### Suggest CODEOWNERS entries (AI-powered)
78
83
 
84
+ > Requires the `[suggest]` extra: `pip install codeowners-coverage[suggest]`
85
+
79
86
  Use local LLM to intelligently suggest team ownership based on git history.
80
87
 
81
88
  **GitHub Token Setup:**
@@ -251,8 +258,8 @@ just release
251
258
  ### Manual commands
252
259
 
253
260
  ```bash
254
- # Install in development mode
255
- uv pip install -e ".[dev]"
261
+ # Install in development mode (all features)
262
+ uv pip install -e ".[dev,suggest]"
256
263
 
257
264
  # Run tests
258
265
  pytest tests/ -v
@@ -19,6 +19,7 @@ src/codeowners_coverage.egg-info/entry_points.txt
19
19
  src/codeowners_coverage.egg-info/requires.txt
20
20
  src/codeowners_coverage.egg-info/top_level.txt
21
21
  tests/test_checker.py
22
+ tests/test_cli.py
22
23
  tests/test_config.py
23
24
  tests/test_directory_consolidator.py
24
25
  tests/test_git_analyzer.py
@@ -2,7 +2,6 @@ pathspec>=0.12.0
2
2
  PyYAML>=6.0
3
3
  click>=8.0
4
4
  requests>=2.31.0
5
- ollama>=0.3.0
6
5
 
7
6
  [dev]
8
7
  pytest>=7.0
@@ -11,3 +10,6 @@ mypy>=1.0
11
10
  ruff>=0.1.0
12
11
  types-requests>=2.31.0
13
12
  types-PyYAML>=6.0
13
+
14
+ [suggest]
15
+ ollama>=0.3.0
@@ -0,0 +1,228 @@
1
+ """Tests for CLI functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from click.testing import CliRunner
11
+
12
+ from codeowners_coverage.cli import cli
13
+
14
+
15
+ def test_check_dirty_baseline_without_flag_exits_2() -> None:
16
+ """Test that check exits with code 2 when baseline is dirty (default behavior)."""
17
+ runner = CliRunner()
18
+
19
+ with tempfile.TemporaryDirectory() as tmpdir:
20
+ # Create CODEOWNERS file
21
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
22
+ codeowners_path.write_text("*.py @python-team\n")
23
+
24
+ # Create baseline with an entry that now has coverage
25
+ baseline_path = Path(tmpdir) / "baseline.txt"
26
+ baseline_path.write_text("covered.py\n")
27
+
28
+ # Create config
29
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
30
+ config_path.write_text(
31
+ f"codeowners_path: {codeowners_path}\n"
32
+ f"baseline_path: {baseline_path}\n"
33
+ f"exclusions: []\n"
34
+ )
35
+
36
+ # Mock get_repository_files to return empty (all files covered)
37
+ with patch(
38
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
39
+ return_value=["covered.py"],
40
+ ):
41
+ result = runner.invoke(cli, ["check", "--config", str(config_path)])
42
+
43
+ # Should exit with code 2 (baseline can be reduced)
44
+ assert result.exit_code == 2
45
+ assert "baseline entries can be removed" in result.output
46
+
47
+
48
+ def test_check_dirty_baseline_with_flag_exits_0() -> None:
49
+ """Test that check exits with code 0 when baseline is dirty but flag is set."""
50
+ runner = CliRunner()
51
+
52
+ with tempfile.TemporaryDirectory() as tmpdir:
53
+ # Create CODEOWNERS file
54
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
55
+ codeowners_path.write_text("*.py @python-team\n")
56
+
57
+ # Create baseline with an entry that now has coverage
58
+ baseline_path = Path(tmpdir) / "baseline.txt"
59
+ baseline_path.write_text("covered.py\n")
60
+
61
+ # Create config
62
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
63
+ config_path.write_text(
64
+ f"codeowners_path: {codeowners_path}\n"
65
+ f"baseline_path: {baseline_path}\n"
66
+ f"exclusions: []\n"
67
+ )
68
+
69
+ # Mock get_repository_files to return covered files
70
+ with patch(
71
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
72
+ return_value=["covered.py"],
73
+ ):
74
+ result = runner.invoke(
75
+ cli, ["check", "--config", str(config_path), "--allow-dirty-baseline"]
76
+ )
77
+
78
+ # Should exit with code 0 (success despite dirty baseline)
79
+ assert result.exit_code == 0
80
+ assert "baseline entries can be removed" in result.output
81
+
82
+
83
+ def test_check_clean_baseline_with_flag_exits_0() -> None:
84
+ """Test that check exits with code 0 when baseline is clean (with or without flag)."""
85
+ runner = CliRunner()
86
+
87
+ with tempfile.TemporaryDirectory() as tmpdir:
88
+ # Create CODEOWNERS file
89
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
90
+ codeowners_path.write_text("*.py @python-team\n")
91
+
92
+ # Create empty baseline
93
+ baseline_path = Path(tmpdir) / "baseline.txt"
94
+ baseline_path.write_text("")
95
+
96
+ # Create config
97
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
98
+ config_path.write_text(
99
+ f"codeowners_path: {codeowners_path}\n"
100
+ f"baseline_path: {baseline_path}\n"
101
+ f"exclusions: []\n"
102
+ )
103
+
104
+ # Mock get_repository_files to return covered files
105
+ with patch(
106
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
107
+ return_value=["covered.py"],
108
+ ):
109
+ result = runner.invoke(
110
+ cli, ["check", "--config", str(config_path), "--allow-dirty-baseline"]
111
+ )
112
+
113
+ # Should exit with code 0
114
+ assert result.exit_code == 0
115
+ assert "baseline entries can be removed" not in result.output
116
+
117
+
118
+ def test_check_json_output_includes_unused_baseline_entries() -> None:
119
+ """Test that JSON output includes unused_baseline_entries field."""
120
+ runner = CliRunner()
121
+
122
+ with tempfile.TemporaryDirectory() as tmpdir:
123
+ # Create CODEOWNERS file
124
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
125
+ codeowners_path.write_text("*.py @python-team\n")
126
+
127
+ # Create baseline with entries that now have coverage
128
+ baseline_path = Path(tmpdir) / "baseline.txt"
129
+ baseline_path.write_text("covered1.py\ncovered2.py\n")
130
+
131
+ # Create config
132
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
133
+ config_path.write_text(
134
+ f"codeowners_path: {codeowners_path}\n"
135
+ f"baseline_path: {baseline_path}\n"
136
+ f"exclusions: []\n"
137
+ )
138
+
139
+ # Mock get_repository_files to return covered files
140
+ with patch(
141
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
142
+ return_value=["covered1.py", "covered2.py"],
143
+ ):
144
+ result = runner.invoke(
145
+ cli, ["check", "--config", str(config_path), "--json"]
146
+ )
147
+
148
+ # Parse JSON output
149
+ output = json.loads(result.output)
150
+
151
+ # Should include unused_baseline_entries
152
+ assert "unused_baseline_entries" in output
153
+ assert "covered1.py" in output["unused_baseline_entries"]
154
+ assert "covered2.py" in output["unused_baseline_entries"]
155
+
156
+
157
+ def test_check_new_uncovered_takes_precedence_over_dirty_baseline() -> None:
158
+ """Test that new uncovered files cause exit 1 even with --allow-dirty-baseline."""
159
+ runner = CliRunner()
160
+
161
+ with tempfile.TemporaryDirectory() as tmpdir:
162
+ # Create CODEOWNERS file
163
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
164
+ codeowners_path.write_text("*.py @python-team\n")
165
+
166
+ # Create baseline with an entry that now has coverage
167
+ baseline_path = Path(tmpdir) / "baseline.txt"
168
+ baseline_path.write_text("covered.py\n")
169
+
170
+ # Create config
171
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
172
+ config_path.write_text(
173
+ f"codeowners_path: {codeowners_path}\n"
174
+ f"baseline_path: {baseline_path}\n"
175
+ f"exclusions: []\n"
176
+ )
177
+
178
+ # Mock get_repository_files to return covered + new uncovered file
179
+ with patch(
180
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
181
+ return_value=["covered.py", "new_uncovered.txt"],
182
+ ):
183
+ result = runner.invoke(
184
+ cli, ["check", "--config", str(config_path), "--allow-dirty-baseline"]
185
+ )
186
+
187
+ # Should exit with code 1 (new uncovered files)
188
+ assert result.exit_code == 1
189
+ assert "new_uncovered.txt" in result.output
190
+
191
+
192
+ def test_check_json_output_with_clean_baseline() -> None:
193
+ """Test that JSON output includes empty unused_baseline_entries when baseline is clean."""
194
+ runner = CliRunner()
195
+
196
+ with tempfile.TemporaryDirectory() as tmpdir:
197
+ # Create CODEOWNERS file
198
+ codeowners_path = Path(tmpdir) / "CODEOWNERS"
199
+ codeowners_path.write_text("*.py @python-team\n")
200
+
201
+ # Create baseline with entries still uncovered
202
+ baseline_path = Path(tmpdir) / "baseline.txt"
203
+ baseline_path.write_text("still_uncovered.txt\n")
204
+
205
+ # Create config
206
+ config_path = Path(tmpdir) / ".codeowners-config.yml"
207
+ config_path.write_text(
208
+ f"codeowners_path: {codeowners_path}\n"
209
+ f"baseline_path: {baseline_path}\n"
210
+ f"exclusions: []\n"
211
+ )
212
+
213
+ # Mock get_repository_files to return baseline file (still uncovered)
214
+ with patch(
215
+ "codeowners_coverage.checker.CoverageChecker.get_repository_files",
216
+ return_value=["covered.py", "still_uncovered.txt"],
217
+ ):
218
+ result = runner.invoke(
219
+ cli, ["check", "--config", str(config_path), "--json"]
220
+ )
221
+
222
+ # Parse JSON output
223
+ output = json.loads(result.output)
224
+
225
+ # Should include empty unused_baseline_entries
226
+ assert "unused_baseline_entries" in output
227
+ assert output["unused_baseline_entries"] == []
228
+ assert result.exit_code == 0
@@ -2,17 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from unittest.mock import patch
5
+ from unittest.mock import MagicMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
+ from codeowners_coverage import ollama_matcher
9
10
  from codeowners_coverage.ollama_matcher import OllamaLLMMatcher
10
11
 
12
+ _mock_ollama_module = MagicMock()
13
+ _mock_ollama_module.list.return_value = []
14
+
11
15
 
12
16
  @pytest.fixture
13
17
  def mock_ollama() -> None:
14
18
  """Mock Ollama availability check."""
15
- with patch("ollama.list", return_value=[]):
19
+ with patch.object(ollama_matcher, "ollama", _mock_ollama_module):
16
20
  yield
17
21
 
18
22
 
@@ -91,7 +95,7 @@ def test_match_file_to_team(mock_ollama: None) -> None:
91
95
  }
92
96
  }
93
97
 
94
- with patch("ollama.chat", return_value=mock_response):
98
+ with patch.object(_mock_ollama_module, "chat", return_value=mock_response):
95
99
  suggestion = matcher.match_file_to_team(
96
100
  filepath="src/components/Button.tsx",
97
101
  contributors=[("alice@example.com", 5)],
@@ -292,7 +296,7 @@ def test_match_file_passes_allowlist(mock_ollama: None) -> None:
292
296
  }
293
297
  }
294
298
 
295
- with patch("ollama.chat", return_value=mock_response):
299
+ with patch.object(_mock_ollama_module, "chat", return_value=mock_response):
296
300
  suggestion = matcher.match_file_to_team(
297
301
  filepath="src/components/Button.tsx",
298
302
  contributors=[("alice@example.com", 5)],