Coverage for src/pullapprove/cli.py: 58%
155 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-10 21:44 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-10 21:44 -0500
1import os
2import sys
3from pathlib import Path
4from textwrap import dedent
6import click
7from pydantic import ValidationError
9from . import git
10from .config import CONFIG_FILENAME, CONFIG_FILENAME_PREFIX, ConfigModel, ConfigModels
11from .matches import match_diff, match_files
12from .printer import MatchesPrinter
15@click.group()
16@click.version_option(package_name="pullapprove")
17@click.pass_context
18def cli(ctx: click.Context) -> None:
19 pass
22@cli.command()
23@click.option("--filename", default=CONFIG_FILENAME, help="Configuration filename")
24def init(filename: str) -> None:
25 """Create a new CODEREVIEW.toml"""
26 config_path = Path(filename)
27 if config_path.exists():
28 click.secho(f"{CONFIG_FILENAME} already exists!", fg="red")
29 sys.exit(1)
31 # Could we use blame to guess?
32 # go straight to agent?
33 # gh auth status can give us the user? or ask what's their username?
34 # keep it simple - agent can do more when I get to it
36 contents = """
37 [[scopes]]
38 name = "default"
39 paths = ["**/*"]
40 request = 1
41 require = 1
42 reviewers = ["<YOU>"]
44 [[scopes]]
45 name = "pullapprove"
46 paths = ["**/CODEREVIEW.toml"]
47 request = 1
48 require = 1
49 reviewers = ["<YOU>"]
50 """
51 config_path.write_text(dedent(contents).strip() + "\n")
52 click.secho(f"Created {filename}")
55@cli.command()
56@click.option("--quiet", is_flag=True)
57def check(quiet: bool) -> ConfigModels:
58 """
59 Validate configuration files
60 """
62 if not quiet:
63 if Path(".pullapprove.yml").exists():
64 click.secho(
65 f"{click.style('[Warning]', fg='yellow')} This repo still contains a PullApprove v3 config file (.pullapprove.yml). Consider migrating it to PullApprove v5."
66 )
67 if Path("CODEOWNERS").exists():
68 click.secho(
69 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file. Consider migrating it to PullApprove v5."
70 )
71 if Path("docs/CODEOWNERS").exists():
72 click.secho(
73 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file (docs/CODEOWNERS). Consider migrating it to PullApprove v5."
74 )
75 if Path(".github/CODEOWNERS").exists():
76 click.secho(
77 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file (.github/CODEOWNERS). Consider migrating it to PullApprove v5."
78 )
80 errors = {}
81 configs = ConfigModels(root={})
83 for root, _, files in os.walk("."):
84 for f in files:
85 if f.startswith(CONFIG_FILENAME_PREFIX):
86 config_path = Path(root) / f
88 if not quiet:
89 click.echo(config_path, nl=False)
90 try:
91 configs.add_config(
92 ConfigModel.from_filesystem(config_path), config_path
93 )
95 if not quiet:
96 click.secho(" -> OK", fg="green")
97 except ValidationError as e:
98 if not quiet:
99 click.secho(" -> ERROR", fg="red")
101 errors[config_path] = e
103 for path, error in errors.items():
104 click.secho(str(path), fg="red")
105 print(error)
107 if errors:
108 raise click.Abort("Configuration validation failed.")
110 # Compile the whole set offline to catch what per-file validation
111 # can't: unknown aliases, missing/circular extends, and invalid
112 # combinations like wildcard+negation. teams=None leaves `@team` refs
113 # unexpanded — the same partial resolution `match`/`coverage` use —
114 # so anything that errors here would also error in production.
115 try:
116 configs.compiled()
117 except (ValidationError, ValueError) as e:
118 click.secho(f"ERROR: {e}", fg="red")
119 raise click.Abort("Configuration validation failed.")
121 if not configs and not quiet:
122 click.secho("No CODEREVIEW.toml files found.", fg="red")
123 sys.exit(1)
125 return configs
128@cli.command()
129@click.option("--changed", is_flag=True, help="Show only changed files")
130@click.option("--staged", is_flag=True, help="Show only staged files")
131@click.option("--diff", is_flag=True, help="Show diff content with matches")
132@click.option(
133 "--by-scope", is_flag=True, help="Organize output by scope instead of by path"
134)
135@click.option(
136 "--scope",
137 multiple=True,
138 help="Filter to show only files matching these scopes (can be used multiple times)",
139)
140@click.argument("paths", nargs=-1, type=click.Path())
141@click.pass_context
142def match(
143 ctx: click.Context,
144 changed: bool,
145 staged: bool,
146 diff: bool,
147 by_scope: bool,
148 scope: tuple[str, ...],
149 paths: tuple[str, ...],
150) -> None:
151 """
152 Show files and their matching scopes
154 If PATHS are provided, only those specific paths will be matched.
155 Directories will be recursively expanded to include all files within them.
156 Otherwise, all files in the repository will be matched.
157 """
158 configs = ctx.invoke(check, quiet=True).compiled()
160 if not configs:
161 click.secho("No valid configurations found.", fg="red")
162 raise click.Abort("No configurations to check.")
164 if paths:
165 # When specific paths are provided, match only those paths
166 if diff or staged or changed:
167 click.secho(
168 "Cannot use --diff, --staged, or --changed with specific paths.",
169 fg="red",
170 )
171 raise click.Abort("Conflicting options.")
173 # Get all git-tracked files and filter by provided paths
174 all_git_files = set(git.git_ls_files(Path(".")))
175 expanded_paths = []
177 for path_str in paths:
178 path = Path(path_str)
179 if path.is_dir():
180 # Filter git files that are within this directory
181 dir_prefix = str(path) + "/"
182 for git_file in all_git_files:
183 if git_file.startswith(dir_prefix) or git_file == str(path):
184 expanded_paths.append(git_file)
185 elif path.is_file() or str(path) in all_git_files:
186 # Include if it's a git-tracked file
187 if str(path) in all_git_files:
188 expanded_paths.append(str(path))
189 else:
190 click.secho(f"File not tracked by git: {path}", fg="yellow")
191 else:
192 click.secho(
193 f"Path does not exist or not tracked by git: {path}", fg="yellow"
194 )
196 matches = match_files(configs, iter(expanded_paths))
197 all_files = expanded_paths
198 elif diff or staged:
199 # Use git diff for these options
200 diff_args = []
201 if staged:
202 diff_args.append("--staged")
204 diff_stream = git.git_diff_stream(Path("."), *diff_args)
205 diff_results = match_diff(configs, diff_stream)
206 matches = diff_results.matches
207 # For diff mode, we only show files in the diff
208 all_files = None
209 elif changed:
210 iterator = git.git_ls_changes(Path("."))
211 matches = match_files(configs, iterator)
212 # For changed mode, we only show changed files
213 all_files = None
214 else:
215 # For normal mode, show all files to see gaps
216 iterator = git.git_ls_files(Path("."))
217 matches = match_files(configs, iterator)
218 # Get all files again for the printer
219 all_files = list(git.git_ls_files(Path(".")))
221 printer = MatchesPrinter(matches, all_files=all_files)
222 if by_scope:
223 printer.print_by_scope(scope_filter=scope)
224 else:
225 printer.print_by_path(scope_filter=scope)
228@cli.command()
229@click.option(
230 "--check",
231 "check_flag",
232 is_flag=True,
233 help="Exit with non-zero status if coverage is incomplete",
234)
235@click.argument("path", type=click.Path(exists=True), default=".")
236@click.pass_context
237def coverage(ctx: click.Context, path: str, check_flag: bool) -> None:
238 """
239 Calculate file coverage for review scopes
240 """
241 configs = ctx.invoke(check, quiet=True).compiled()
243 num_matched = 0
244 num_total = 0
245 uncovered_files = []
247 # First, get all files to know the total count for progress bar
248 all_files = list(git.git_ls_files(Path(path)))
250 if not all_files:
251 click.echo("No files found")
252 return
254 # Process files with progress bar
255 with click.progressbar(
256 all_files, label="Analyzing coverage", show_percent=True, show_pos=True
257 ) as files:
258 # Use match_files to get proper scope matching including code patterns
259 results = match_files(configs, iter(files))
261 # Count files with and without scope matches
262 for path_str, path_match in results.paths.items():
263 if path_match.scopes:
264 num_matched += 1
265 else:
266 uncovered_files.append(path_str)
267 num_total += 1
269 # Also count files that weren't in the results (no scope matches at all)
270 for f in all_files:
271 if f not in results.paths:
272 uncovered_files.append(f)
273 num_total += 1
275 percentage = (num_matched / num_total) * 100
277 # Display coverage statistics
278 if num_matched == num_total:
279 click.secho(f"\n✓ {num_matched}/{num_total} files covered (100.0%)", fg="green")
280 else:
281 # Show uncovered files
282 if uncovered_files:
283 click.echo("\nUncovered files:")
284 for file in sorted(uncovered_files)[:10]: # Show first 10
285 click.echo(f" - {file}")
286 if len(uncovered_files) > 10:
287 click.echo(f" ...and {len(uncovered_files) - 10} more")
289 click.secho(
290 f"\n{num_matched}/{num_total} files covered ({percentage:.1f}%)",
291 fg="yellow",
292 )
294 if check_flag and num_matched != num_total:
295 sys.exit(1)
298# list - find open PRs, find status url and send json request (needs PA token)