codeowners-coverage 0.1.0__py3-none-any.whl
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.
- codeowners_coverage/__init__.py +9 -0
- codeowners_coverage/__main__.py +12 -0
- codeowners_coverage/checker.py +157 -0
- codeowners_coverage/cli.py +130 -0
- codeowners_coverage/config.py +77 -0
- codeowners_coverage/matcher.py +88 -0
- codeowners_coverage-0.1.0.dist-info/METADATA +158 -0
- codeowners_coverage-0.1.0.dist-info/RECORD +11 -0
- codeowners_coverage-0.1.0.dist-info/WHEEL +5 -0
- codeowners_coverage-0.1.0.dist-info/entry_points.txt +2 -0
- codeowners_coverage-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Configuration loading for codeowners-coverage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Config:
|
|
14
|
+
"""Configuration for codeowners-coverage."""
|
|
15
|
+
|
|
16
|
+
codeowners_path: str = ".github/CODEOWNERS"
|
|
17
|
+
baseline_path: str = ".github/codeowners-coverage-baseline.txt"
|
|
18
|
+
exclusions: List[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def load(cls, config_path: str = ".codeowners-config.yml") -> Config:
|
|
22
|
+
"""
|
|
23
|
+
Load configuration from YAML file.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_path: Path to the configuration file
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Config object with loaded settings
|
|
30
|
+
"""
|
|
31
|
+
config_file = Path(config_path)
|
|
32
|
+
|
|
33
|
+
if not config_file.exists():
|
|
34
|
+
# Return default configuration with default exclusions
|
|
35
|
+
return cls(exclusions=cls.default_exclusions())
|
|
36
|
+
|
|
37
|
+
with open(config_file) as f:
|
|
38
|
+
data = yaml.safe_load(f) or {}
|
|
39
|
+
|
|
40
|
+
return cls(
|
|
41
|
+
codeowners_path=data.get("codeowners_path", cls.codeowners_path),
|
|
42
|
+
baseline_path=data.get("baseline_path", cls.baseline_path),
|
|
43
|
+
exclusions=data.get("exclusions", cls.default_exclusions()),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def default_exclusions() -> List[str]:
|
|
48
|
+
"""
|
|
49
|
+
Default exclusion patterns.
|
|
50
|
+
|
|
51
|
+
These are common build artifacts and dependency directories
|
|
52
|
+
that typically don't need CODEOWNERS coverage.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of default exclusion patterns
|
|
56
|
+
"""
|
|
57
|
+
return [
|
|
58
|
+
# Python artifacts
|
|
59
|
+
"**/__pycache__/**",
|
|
60
|
+
"**/*.pyc",
|
|
61
|
+
"**/*.pyo",
|
|
62
|
+
"**/*.egg-info/**",
|
|
63
|
+
".venv/**",
|
|
64
|
+
".tox/**",
|
|
65
|
+
".pytest_cache/**",
|
|
66
|
+
".mypy_cache/**",
|
|
67
|
+
# JavaScript artifacts
|
|
68
|
+
"node_modules/**",
|
|
69
|
+
"dist/**",
|
|
70
|
+
"build/**",
|
|
71
|
+
"**/coverage/**",
|
|
72
|
+
# Version control
|
|
73
|
+
".git/**",
|
|
74
|
+
# Build outputs
|
|
75
|
+
"htmlcov/**",
|
|
76
|
+
".coverage",
|
|
77
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Pattern matching for CODEOWNERS files using pathspec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import pathspec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodeOwnersPatternMatcher:
|
|
12
|
+
"""Match file paths against CODEOWNERS patterns."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, codeowners_path: str) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Load and parse CODEOWNERS file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
codeowners_path: Path to the CODEOWNERS file
|
|
20
|
+
"""
|
|
21
|
+
self.codeowners_path = codeowners_path
|
|
22
|
+
self.patterns = self._parse_codeowners(codeowners_path)
|
|
23
|
+
# Use gitignore for gitignore-style patterns
|
|
24
|
+
self.spec = pathspec.PathSpec.from_lines("gitignore", self.patterns)
|
|
25
|
+
|
|
26
|
+
def _parse_codeowners(self, path: str) -> List[str]:
|
|
27
|
+
"""
|
|
28
|
+
Extract patterns from CODEOWNERS file.
|
|
29
|
+
|
|
30
|
+
The CODEOWNERS format is:
|
|
31
|
+
pattern @owner1 @owner2
|
|
32
|
+
|
|
33
|
+
We only care about the patterns, not the owners.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
path: Path to CODEOWNERS file
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of patterns extracted from the file
|
|
40
|
+
"""
|
|
41
|
+
patterns = []
|
|
42
|
+
codeowners_file = Path(path)
|
|
43
|
+
|
|
44
|
+
if not codeowners_file.exists():
|
|
45
|
+
raise FileNotFoundError(f"CODEOWNERS file not found: {path}")
|
|
46
|
+
|
|
47
|
+
with open(codeowners_file) as f:
|
|
48
|
+
for line in f:
|
|
49
|
+
line = line.strip()
|
|
50
|
+
# Skip comments and empty lines
|
|
51
|
+
if not line or line.startswith("#"):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Pattern is first token, owners follow
|
|
55
|
+
parts = line.split()
|
|
56
|
+
if parts:
|
|
57
|
+
pattern = parts[0]
|
|
58
|
+
patterns.append(pattern)
|
|
59
|
+
|
|
60
|
+
return patterns
|
|
61
|
+
|
|
62
|
+
def matches(self, filepath: str) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Check if filepath is covered by any CODEOWNERS pattern.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
filepath: File path to check (relative to repo root)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if the file matches any CODEOWNERS pattern
|
|
71
|
+
"""
|
|
72
|
+
return self.spec.match_file(filepath)
|
|
73
|
+
|
|
74
|
+
def get_matching_pattern(self, filepath: str) -> str | None:
|
|
75
|
+
"""
|
|
76
|
+
Get the first pattern that matches the given filepath.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
filepath: File path to check (relative to repo root)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The matching pattern, or None if no match
|
|
83
|
+
"""
|
|
84
|
+
for pattern in self.patterns:
|
|
85
|
+
pattern_spec = pathspec.PathSpec.from_lines("gitignore", [pattern])
|
|
86
|
+
if pattern_spec.match_file(filepath):
|
|
87
|
+
return pattern
|
|
88
|
+
return None
|
|
@@ -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,11 @@
|
|
|
1
|
+
codeowners_coverage/__init__.py,sha256=-wSyZqa_AYCSXRvyuHoVavpR6HevUoUcGgyONbAg36Q,244
|
|
2
|
+
codeowners_coverage/__main__.py,sha256=dAxC9oNG2vWzxip2dYJL_rl0G8jqUuPu-5SMPEEplJ4,169
|
|
3
|
+
codeowners_coverage/checker.py,sha256=AILAiKbSGzr7j8xCJGc-QNKQ7849e8JinfA5w-XbUrE,4969
|
|
4
|
+
codeowners_coverage/cli.py,sha256=FBf9iaiUgiuj5-6BYC9k5ruYXQV9EQc8otVPBo_8o-c,4852
|
|
5
|
+
codeowners_coverage/config.py,sha256=DIlBOjE5R_bNTI0uorSCVlJZte8iUycAWkXyzH04RZ4,2143
|
|
6
|
+
codeowners_coverage/matcher.py,sha256=vj-TkLAqI0HUaaiNMAVcKMz6ZDmOeihil_zr9UKtGNo,2569
|
|
7
|
+
codeowners_coverage-0.1.0.dist-info/METADATA,sha256=0GNMBoSNMe1-y5v34G8urDeWkbEH2kEZTsHCE2AEGWc,2885
|
|
8
|
+
codeowners_coverage-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
9
|
+
codeowners_coverage-0.1.0.dist-info/entry_points.txt,sha256=luzL4lUNPBAX-alRgh9EkEXpESZ0hWdnyWhvWUaLza4,74
|
|
10
|
+
codeowners_coverage-0.1.0.dist-info/top_level.txt,sha256=7i-oxjSacWaiBnAPUmHtqBVCouuW65HKMgbO4yDayeg,20
|
|
11
|
+
codeowners_coverage-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codeowners_coverage
|