codeowners-coverage 0.1.0__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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeowners-coverage
3
+ Version: 0.1.0
4
+ Summary: Measure and enforce CODEOWNERS coverage
5
+ Author-email: Sentry Team <hello@sentry.io>
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pathspec>=0.12.0
10
+ Requires-Dist: PyYAML>=6.0
11
+ Requires-Dist: click>=8.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
15
+ Requires-Dist: mypy>=1.0; extra == "dev"
16
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
17
+
18
+ # CODEOWNERS Coverage
19
+
20
+ A tool to measure and enforce CODEOWNERS coverage in your repository.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install codeowners-coverage
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Check coverage
31
+
32
+ ```bash
33
+ codeowners-coverage check
34
+ ```
35
+
36
+ ### Generate baseline
37
+
38
+ ```bash
39
+ codeowners-coverage baseline
40
+ ```
41
+
42
+ ### Configuration
43
+
44
+ Create a `.codeowners-config.yml` file in your repository root:
45
+
46
+ ```yaml
47
+ # Path to CODEOWNERS file
48
+ codeowners_path: ".github/CODEOWNERS"
49
+
50
+ # Path to baseline file
51
+ baseline_path: ".github/codeowners-coverage-baseline.txt"
52
+
53
+ # File patterns to exclude from coverage checking
54
+ exclusions:
55
+ - "**/__pycache__/**"
56
+ - "**/*.pyc"
57
+ - "node_modules/**"
58
+ - "dist/**"
59
+ - ".venv/**"
60
+ ```
61
+
62
+ ## GitHub Actions Integration
63
+
64
+ Add a workflow to check CODEOWNERS coverage on pull requests:
65
+
66
+ ```yaml
67
+ name: CODEOWNERS Coverage
68
+ on: [pull_request]
69
+
70
+ jobs:
71
+ check:
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+ - uses: actions/setup-python@v5
76
+ with:
77
+ python-version: '3.11'
78
+ - run: pip install codeowners-coverage
79
+ - run: codeowners-coverage check
80
+ ```
81
+
82
+ ## Features
83
+
84
+ - **Pattern Matching**: Supports all CODEOWNERS patterns (wildcards, globstars, etc.)
85
+ - **Baseline Support**: Track progress incrementally with a baseline allowlist
86
+ - **Configurable**: Customize exclusions and paths via YAML config
87
+ - **Fast**: Uses `pathspec` library for efficient pattern matching
88
+ - **GitHub Actions Ready**: Easy integration with CI/CD pipelines
89
+
90
+ ## Development
91
+
92
+ ### Using just (recommended)
93
+
94
+ This project includes a `justfile` with common development commands:
95
+
96
+ ```bash
97
+ # Install package in development mode
98
+ just install
99
+
100
+ # Run all tests
101
+ just test
102
+
103
+ # Run tests with coverage report
104
+ just test-cov
105
+
106
+ # Run type checking
107
+ just typecheck
108
+
109
+ # Run linting
110
+ just lint
111
+
112
+ # Run all checks (tests, typecheck, lint)
113
+ just check
114
+
115
+ # Clean build artifacts
116
+ just clean
117
+
118
+ # Build the package
119
+ just build
120
+
121
+ # Or build with uv (faster)
122
+ just build-uv
123
+
124
+ # Format code
125
+ just format
126
+
127
+ # Publish to Test PyPI
128
+ just publish-test
129
+
130
+ # Publish to production PyPI
131
+ just publish
132
+
133
+ # Full release workflow
134
+ just release
135
+ ```
136
+
137
+ ### Manual commands
138
+
139
+ ```bash
140
+ # Install in development mode
141
+ uv pip install -e ".[dev]"
142
+
143
+ # Run tests
144
+ pytest tests/ -v
145
+
146
+ # Run with coverage
147
+ pytest tests/ -v --cov
148
+
149
+ # Build package
150
+ python -m build
151
+
152
+ # Publish to PyPI
153
+ twine upload dist/*
154
+ ```
155
+
156
+ ## License
157
+
158
+ Apache-2.0
@@ -0,0 +1,141 @@
1
+ # CODEOWNERS Coverage
2
+
3
+ A tool to measure and enforce CODEOWNERS coverage in your repository.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install codeowners-coverage
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Check coverage
14
+
15
+ ```bash
16
+ codeowners-coverage check
17
+ ```
18
+
19
+ ### Generate baseline
20
+
21
+ ```bash
22
+ codeowners-coverage baseline
23
+ ```
24
+
25
+ ### Configuration
26
+
27
+ Create a `.codeowners-config.yml` file in your repository root:
28
+
29
+ ```yaml
30
+ # Path to CODEOWNERS file
31
+ codeowners_path: ".github/CODEOWNERS"
32
+
33
+ # Path to baseline file
34
+ baseline_path: ".github/codeowners-coverage-baseline.txt"
35
+
36
+ # File patterns to exclude from coverage checking
37
+ exclusions:
38
+ - "**/__pycache__/**"
39
+ - "**/*.pyc"
40
+ - "node_modules/**"
41
+ - "dist/**"
42
+ - ".venv/**"
43
+ ```
44
+
45
+ ## GitHub Actions Integration
46
+
47
+ Add a workflow to check CODEOWNERS coverage on pull requests:
48
+
49
+ ```yaml
50
+ name: CODEOWNERS Coverage
51
+ on: [pull_request]
52
+
53
+ jobs:
54
+ check:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ - uses: actions/setup-python@v5
59
+ with:
60
+ python-version: '3.11'
61
+ - run: pip install codeowners-coverage
62
+ - run: codeowners-coverage check
63
+ ```
64
+
65
+ ## Features
66
+
67
+ - **Pattern Matching**: Supports all CODEOWNERS patterns (wildcards, globstars, etc.)
68
+ - **Baseline Support**: Track progress incrementally with a baseline allowlist
69
+ - **Configurable**: Customize exclusions and paths via YAML config
70
+ - **Fast**: Uses `pathspec` library for efficient pattern matching
71
+ - **GitHub Actions Ready**: Easy integration with CI/CD pipelines
72
+
73
+ ## Development
74
+
75
+ ### Using just (recommended)
76
+
77
+ This project includes a `justfile` with common development commands:
78
+
79
+ ```bash
80
+ # Install package in development mode
81
+ just install
82
+
83
+ # Run all tests
84
+ just test
85
+
86
+ # Run tests with coverage report
87
+ just test-cov
88
+
89
+ # Run type checking
90
+ just typecheck
91
+
92
+ # Run linting
93
+ just lint
94
+
95
+ # Run all checks (tests, typecheck, lint)
96
+ just check
97
+
98
+ # Clean build artifacts
99
+ just clean
100
+
101
+ # Build the package
102
+ just build
103
+
104
+ # Or build with uv (faster)
105
+ just build-uv
106
+
107
+ # Format code
108
+ just format
109
+
110
+ # Publish to Test PyPI
111
+ just publish-test
112
+
113
+ # Publish to production PyPI
114
+ just publish
115
+
116
+ # Full release workflow
117
+ just release
118
+ ```
119
+
120
+ ### Manual commands
121
+
122
+ ```bash
123
+ # Install in development mode
124
+ uv pip install -e ".[dev]"
125
+
126
+ # Run tests
127
+ pytest tests/ -v
128
+
129
+ # Run with coverage
130
+ pytest tests/ -v --cov
131
+
132
+ # Build package
133
+ python -m build
134
+
135
+ # Publish to PyPI
136
+ twine upload dist/*
137
+ ```
138
+
139
+ ## License
140
+
141
+ Apache-2.0
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "codeowners-coverage"
7
+ version = "0.1.0"
8
+ description = "Measure and enforce CODEOWNERS coverage"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [
13
+ { name = "Sentry Team", email = "hello@sentry.io" }
14
+ ]
15
+ dependencies = [
16
+ "pathspec>=0.12.0", # GitIgnore pattern matching
17
+ "PyYAML>=6.0", # Config file parsing
18
+ "click>=8.0", # CLI framework
19
+ ]
20
+
21
+ [project.scripts]
22
+ codeowners-coverage = "codeowners_coverage.__main__:main"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=7.0",
27
+ "pytest-cov>=4.0",
28
+ "mypy>=1.0",
29
+ "ruff>=0.1.0",
30
+ ]
31
+
32
+ [tool.ruff]
33
+ line-length = 100
34
+ target-version = "py39"
35
+
36
+ [tool.mypy]
37
+ python_version = "3.9"
38
+ strict = true
39
+ warn_return_any = true
40
+ warn_unused_configs = true
41
+ disallow_untyped_defs = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """CODEOWNERS coverage checking tool."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .checker import CoverageChecker
6
+ from .config import Config
7
+ from .matcher import CodeOwnersPatternMatcher
8
+
9
+ __all__ = ["CoverageChecker", "Config", "CodeOwnersPatternMatcher"]
@@ -0,0 +1,12 @@
1
+ """Entry point for codeowners-coverage CLI."""
2
+
3
+ from .cli import cli
4
+
5
+
6
+ def main() -> None:
7
+ """Main entry point."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,157 @@
1
+ """Core coverage checking logic for CODEOWNERS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Dict, List, Set
8
+
9
+ import pathspec
10
+
11
+ from .config import Config
12
+ from .matcher import CodeOwnersPatternMatcher
13
+
14
+
15
+ class CoverageChecker:
16
+ """Check CODEOWNERS coverage for repository files."""
17
+
18
+ def __init__(self, config: Config) -> None:
19
+ """
20
+ Initialize coverage checker.
21
+
22
+ Args:
23
+ config: Configuration object
24
+ """
25
+ self.config = config
26
+ self.matcher = CodeOwnersPatternMatcher(config.codeowners_path)
27
+ self.exclusions = pathspec.PathSpec.from_lines("gitignore", config.exclusions)
28
+
29
+ def get_repository_files(self) -> List[str]:
30
+ """
31
+ Get all files in repository using git ls-files.
32
+
33
+ Returns:
34
+ List of file paths relative to repository root
35
+ """
36
+ result = subprocess.run(
37
+ ["git", "ls-files"],
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+ files = result.stdout.strip().split("\n")
43
+ # Filter out empty strings
44
+ return [f for f in files if f]
45
+
46
+ def _load_baseline(self) -> Set[str]:
47
+ """
48
+ Load baseline file of allowed uncovered files.
49
+
50
+ Returns:
51
+ Set of file paths that are allowed to be uncovered
52
+ """
53
+ baseline_path = Path(self.config.baseline_path)
54
+
55
+ if not baseline_path.exists():
56
+ return set()
57
+
58
+ baseline_files = set()
59
+ with open(baseline_path) as f:
60
+ for line in f:
61
+ line = line.strip()
62
+ # Skip comments and empty lines
63
+ if line and not line.startswith("#"):
64
+ baseline_files.add(line)
65
+
66
+ return baseline_files
67
+
68
+ def check_coverage(self, files: List[str] | None = None) -> Dict[str, object]:
69
+ """
70
+ Check which files lack CODEOWNERS coverage.
71
+
72
+ Args:
73
+ files: Optional list of specific files to check.
74
+ If None, checks all repository files.
75
+
76
+ Returns:
77
+ Dictionary with coverage statistics:
78
+ {
79
+ "total_files": int,
80
+ "covered_files": int,
81
+ "uncovered_files": [list of uncovered file paths],
82
+ "baseline_files": [list of baseline file paths],
83
+ "coverage_percentage": float
84
+ }
85
+ """
86
+ if files is None:
87
+ files = self.get_repository_files()
88
+
89
+ # Filter excluded files
90
+ filtered_files = [f for f in files if not self.exclusions.match_file(f)]
91
+
92
+ # Check coverage
93
+ uncovered = [f for f in filtered_files if not self.matcher.matches(f)]
94
+
95
+ # Load baseline
96
+ baseline = self._load_baseline()
97
+
98
+ # Separate new uncovered files from baseline files
99
+ new_uncovered = [f for f in uncovered if f not in baseline]
100
+ baseline_files = [f for f in uncovered if f in baseline]
101
+
102
+ # Calculate coverage
103
+ total_files = len(filtered_files)
104
+ covered_files = total_files - len(uncovered)
105
+ coverage_percentage = (covered_files / total_files * 100) if total_files > 0 else 100.0
106
+
107
+ return {
108
+ "total_files": total_files,
109
+ "covered_files": covered_files,
110
+ "uncovered_files": new_uncovered,
111
+ "baseline_files": baseline_files,
112
+ "coverage_percentage": coverage_percentage,
113
+ }
114
+
115
+ def generate_baseline(self, files: List[str] | None = None) -> List[str]:
116
+ """
117
+ Generate a baseline of all currently uncovered files.
118
+
119
+ Args:
120
+ files: Optional list of specific files to check.
121
+ If None, checks all repository files.
122
+
123
+ Returns:
124
+ List of uncovered file paths (sorted)
125
+ """
126
+ if files is None:
127
+ files = self.get_repository_files()
128
+
129
+ # Filter excluded files
130
+ filtered_files = [f for f in files if not self.exclusions.match_file(f)]
131
+
132
+ # Check coverage
133
+ uncovered = [f for f in filtered_files if not self.matcher.matches(f)]
134
+
135
+ return sorted(uncovered)
136
+
137
+ def write_baseline(self, baseline_files: List[str]) -> None:
138
+ """
139
+ Write baseline file.
140
+
141
+ Args:
142
+ baseline_files: List of file paths to write to baseline
143
+ """
144
+ baseline_path = Path(self.config.baseline_path)
145
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
146
+
147
+ with open(baseline_path, "w") as f:
148
+ f.write("# CODEOWNERS Coverage Baseline\n")
149
+ f.write("# Files lacking CODEOWNERS coverage (sorted)\n")
150
+ f.write("# Goal: Reduce this list to zero\n")
151
+ f.write("#\n")
152
+ f.write("# Generated by: codeowners-coverage baseline\n")
153
+ f.write("#\n")
154
+ f.write("\n")
155
+
156
+ for filepath in baseline_files:
157
+ f.write(f"{filepath}\n")
@@ -0,0 +1,130 @@
1
+ """CLI interface for codeowners-coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import List, Tuple
8
+
9
+ import click
10
+
11
+ from .checker import CoverageChecker
12
+ from .config import Config
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(version="0.1.0")
17
+ def cli() -> None:
18
+ """CODEOWNERS coverage checking tool."""
19
+ pass
20
+
21
+
22
+ @cli.command()
23
+ @click.option("--json", "output_json", is_flag=True, help="Output JSON format")
24
+ @click.option("--files", multiple=True, help="Specific files to check")
25
+ @click.option("--config", default=".codeowners-config.yml", help="Config file path")
26
+ def check(output_json: bool, files: Tuple[str, ...], config: str) -> None:
27
+ """
28
+ Check CODEOWNERS coverage.
29
+
30
+ Validates that all files in the repository have CODEOWNERS coverage.
31
+ Files in the baseline are allowed to be uncovered.
32
+ New uncovered files will cause the check to fail.
33
+ """
34
+ try:
35
+ cfg = Config.load(config)
36
+ checker = CoverageChecker(cfg)
37
+
38
+ file_list: List[str] | None = list(files) if files else None
39
+ result = checker.check_coverage(file_list)
40
+
41
+ if output_json:
42
+ click.echo(json.dumps(result, indent=2))
43
+ else:
44
+ _print_human_readable_result(result)
45
+
46
+ # Exit with error if there are new uncovered files
47
+ if result["uncovered_files"]:
48
+ sys.exit(1)
49
+
50
+ # Exit with code 2 if baseline can be reduced (positive signal)
51
+ # Check if any baseline files are now covered
52
+ baseline_set = set(result["baseline_files"])
53
+ current_uncovered = checker.generate_baseline(file_list)
54
+ current_uncovered_set = set(current_uncovered)
55
+
56
+ if len(baseline_set - current_uncovered_set) > 0:
57
+ if not output_json:
58
+ removable_files = sorted(baseline_set - current_uncovered_set)
59
+ click.echo(f"\nšŸŽ‰ Great news! {len(removable_files)} files can be removed from the baseline:")
60
+ for f in removable_files[:10]: # Show first 10
61
+ click.echo(f" - {f}")
62
+ if len(removable_files) > 10:
63
+ click.echo(f" ... and {len(removable_files) - 10} more")
64
+ click.echo("\nThese files now have CODEOWNERS coverage! Update the baseline:")
65
+ click.echo(" codeowners-coverage baseline")
66
+ sys.exit(2)
67
+
68
+ except FileNotFoundError as e:
69
+ click.echo(f"āŒ Error: {e}", err=True)
70
+ sys.exit(1)
71
+ except Exception as e:
72
+ click.echo(f"āŒ Unexpected error: {e}", err=True)
73
+ sys.exit(1)
74
+
75
+
76
+ @cli.command()
77
+ @click.option("--config", default=".codeowners-config.yml", help="Config file path")
78
+ @click.option("--files", multiple=True, help="Specific files to check")
79
+ def baseline(config: str, files: Tuple[str, ...]) -> None:
80
+ """
81
+ Generate or update baseline file of uncovered files.
82
+
83
+ Creates a baseline of all currently uncovered files. This allows
84
+ incremental improvement by preventing new uncovered files while
85
+ allowing existing gaps.
86
+ """
87
+ try:
88
+ cfg = Config.load(config)
89
+ checker = CoverageChecker(cfg)
90
+
91
+ file_list: List[str] | None = list(files) if files else None
92
+ baseline_files = checker.generate_baseline(file_list)
93
+
94
+ # Write baseline
95
+ checker.write_baseline(baseline_files)
96
+
97
+ click.echo(f"āœ… Baseline written to {cfg.baseline_path}")
98
+ click.echo(f"šŸ“Š {len(baseline_files)} uncovered files in baseline")
99
+
100
+ if baseline_files:
101
+ click.echo(f"\nšŸ’” Goal: Reduce this list to zero by adding CODEOWNERS coverage")
102
+
103
+ except FileNotFoundError as e:
104
+ click.echo(f"āŒ Error: {e}", err=True)
105
+ sys.exit(1)
106
+ except Exception as e:
107
+ click.echo(f"āŒ Unexpected error: {e}", err=True)
108
+ sys.exit(1)
109
+
110
+
111
+ def _print_human_readable_result(result: dict) -> None:
112
+ """Print coverage check result in human-readable format."""
113
+ total = result["total_files"]
114
+ covered = result["covered_files"]
115
+ uncovered = result["uncovered_files"]
116
+ baseline = result["baseline_files"]
117
+ percentage = result["coverage_percentage"]
118
+
119
+ if uncovered:
120
+ click.echo("āŒ CODEOWNERS Coverage Check Failed\n")
121
+ click.echo(f"The following {len(uncovered)} files lack CODEOWNERS coverage:")
122
+ for f in uncovered:
123
+ click.echo(f" - {f}")
124
+ click.echo("\nPlease add these files to .github/CODEOWNERS with appropriate owners.")
125
+ click.echo("\nšŸ’” Need help? Check the team mapping in the CODEOWNERS file")
126
+ click.echo(f"\nšŸ“Š Current status: {len(baseline)} files in baseline (unchanged)")
127
+ else:
128
+ click.echo(f"āœ… CODEOWNERS Coverage: {percentage:.1f}% ({covered}/{total} files covered)")
129
+ if baseline:
130
+ click.echo(f"\nšŸ’” Baseline: {len(baseline)} files still need coverage")