Coverage for src/pullapprove/cli.py: 0%
150 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-09-30 14:30 -0500
« prev ^ index » next coverage.py v7.8.2, created at 2025-09-30 14:30 -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# Most often used as `pullapprove`
16# @click.group(invoke_without_command=True)
17@click.group()
18@click.version_option(package_name="pullapprove")
19@click.pass_context
20def cli(ctx: click.Context) -> None:
21 pass
22 # if ctx.invoked_subcommand is None:
23 # ctx.invoke(review)
26# Most often used as `pullapprove review`
27# @cli.command()
28# @click.pass_context
29# def review(ctx):
30# """
31# Show changed files that need review
32# """
34# # What might we be reviewing?
35# # - PR number / url
36# # - branch
37# # - diff
39# # This is an alias for files --changed
40# ctx.invoke(files, changed=True)
43@cli.command()
44@click.option("--filename", default=CONFIG_FILENAME, help="Configuration filename")
45def init(filename: str) -> None:
46 """Create a new CODEREVIEW.toml"""
47 config_path = Path(filename)
48 if config_path.exists():
49 click.secho(f"{CONFIG_FILENAME} already exists!", fg="red")
50 sys.exit(1)
52 # Could we use blame to guess?
53 # go straight to agent?
54 # gh auth status can give us the user? or ask what's their username?
55 # keep it simple - agent can do more when I get to it
57 contents = """
58 [[scopes]]
59 name = "default"
60 paths = ["**/*"]
61 request = 1
62 require = 1
63 reviewers = ["<YOU>"]
65 [[scopes]]
66 name = "pullapprove"
67 paths = ["**/CODEREVIEW.toml"]
68 request = 1
69 require = 1
70 reviewers = ["<YOU>"]
71 """
72 config_path.write_text(dedent(contents).strip() + "\n")
73 click.secho(f"Created {filename}")
76@cli.command()
77@click.option("--quiet", is_flag=True)
78def check(quiet: bool) -> ConfigModels:
79 """
80 Validate configuration files
81 """
83 if not quiet:
84 if Path(".pullapprove.yml").exists():
85 click.secho(
86 f"{click.style('[Warning]', fg='yellow')} This repo still contains a PullApprove v3 config file (.pullapprove.yml). Consider migrating it to PullApprove v5."
87 )
88 if Path("CODEOWNERS").exists():
89 click.secho(
90 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file. Consider migrating it to PullApprove v5."
91 )
92 if Path("docs/CODEOWNERS").exists():
93 click.secho(
94 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file (docs/CODEOWNERS). Consider migrating it to PullApprove v5."
95 )
96 if Path(".github/CODEOWNERS").exists():
97 click.secho(
98 f"{click.style('[Warning]', fg='yellow')} This repo still contains a CODEOWNERS file (.github/CODEOWNERS). Consider migrating it to PullApprove v5."
99 )
101 errors = {}
102 configs = ConfigModels(root={})
104 for root, _, files in os.walk("."):
105 for f in files:
106 if f.startswith(CONFIG_FILENAME_PREFIX):
107 config_path = Path(root) / f
109 if not quiet:
110 click.echo(config_path, nl=False)
111 try:
112 configs.add_config(
113 ConfigModel.from_filesystem(config_path), config_path
114 )
116 if not quiet:
117 click.secho(" -> OK", fg="green")
118 except ValidationError as e:
119 if not quiet:
120 click.secho(" -> ERROR", fg="red")
122 errors[config_path] = e
124 for path, error in errors.items():
125 click.secho(str(path), fg="red")
126 print(error)
128 if errors:
129 raise click.Abort("Configuration validation failed.")
131 if not configs and not quiet:
132 click.secho("No CODEREVIEW.toml files found.", fg="red")
133 sys.exit(1)
135 return configs
138@cli.command()
139@click.option("--changed", is_flag=True, help="Show only changed files")
140@click.option("--staged", is_flag=True, help="Show only staged files")
141@click.option("--diff", is_flag=True, help="Show diff content with matches")
142@click.option(
143 "--by-scope", is_flag=True, help="Organize output by scope instead of by path"
144)
145@click.option(
146 "--scope",
147 multiple=True,
148 help="Filter to show only files matching these scopes (can be used multiple times)",
149)
150@click.argument("paths", nargs=-1, type=click.Path())
151@click.pass_context
152def match(
153 ctx: click.Context,
154 changed: bool,
155 staged: bool,
156 diff: bool,
157 by_scope: bool,
158 scope: tuple[str, ...],
159 paths: tuple[str, ...],
160) -> None:
161 """
162 Show files and their matching scopes
164 If PATHS are provided, only those specific paths will be matched.
165 Directories will be recursively expanded to include all files within them.
166 Otherwise, all files in the repository will be matched.
167 """
168 configs = ctx.invoke(check, quiet=True)
170 if not configs:
171 click.secho("No valid configurations found.", fg="red")
172 raise click.Abort("No configurations to check.")
174 if paths:
175 # When specific paths are provided, match only those paths
176 if diff or staged or changed:
177 click.secho(
178 "Cannot use --diff, --staged, or --changed with specific paths.",
179 fg="red",
180 )
181 raise click.Abort("Conflicting options.")
183 # Get all git-tracked files and filter by provided paths
184 all_git_files = set(git.git_ls_files(Path(".")))
185 expanded_paths = []
187 for path_str in paths:
188 path = Path(path_str)
189 if path.is_dir():
190 # Filter git files that are within this directory
191 dir_prefix = str(path) + "/"
192 for git_file in all_git_files:
193 if git_file.startswith(dir_prefix) or git_file == str(path):
194 expanded_paths.append(git_file)
195 elif path.is_file() or str(path) in all_git_files:
196 # Include if it's a git-tracked file
197 if str(path) in all_git_files:
198 expanded_paths.append(str(path))
199 else:
200 click.secho(f"File not tracked by git: {path}", fg="yellow")
201 else:
202 click.secho(
203 f"Path does not exist or not tracked by git: {path}", fg="yellow"
204 )
206 matches = match_files(configs, iter(expanded_paths))
207 all_files = expanded_paths
208 elif diff or staged:
209 # Use git diff for these options
210 diff_args = []
211 if staged:
212 diff_args.append("--staged")
214 diff_stream = git.git_diff_stream(Path("."), *diff_args)
215 diff_results = match_diff(configs, diff_stream)
216 matches = diff_results.matches
217 # For diff mode, we only show files in the diff
218 all_files = None
219 elif changed:
220 iterator = git.git_ls_changes(Path("."))
221 matches = match_files(configs, iterator)
222 # For changed mode, we only show changed files
223 all_files = None
224 else:
225 # For normal mode, show all files to see gaps
226 iterator = git.git_ls_files(Path("."))
227 matches = match_files(configs, iterator)
228 # Get all files again for the printer
229 all_files = list(git.git_ls_files(Path(".")))
231 printer = MatchesPrinter(matches, all_files=all_files)
232 if by_scope:
233 printer.print_by_scope(scope_filter=scope)
234 else:
235 printer.print_by_path(scope_filter=scope)
238@cli.command()
239@click.option(
240 "--check",
241 "check_flag",
242 is_flag=True,
243 help="Exit with non-zero status if coverage is incomplete",
244)
245@click.argument("path", type=click.Path(exists=True), default=".")
246@click.pass_context
247def coverage(ctx: click.Context, path: str, check_flag: bool) -> None:
248 """
249 Calculate file coverage for review scopes
250 """
251 configs = ctx.invoke(check, quiet=True)
253 num_matched = 0
254 num_total = 0
255 uncovered_files = []
257 # First, get all files to know the total count for progress bar
258 all_files = list(git.git_ls_files(Path(path)))
260 if not all_files:
261 click.echo("No files found")
262 return
264 # Process files with progress bar
265 with click.progressbar(
266 all_files, label="Analyzing coverage", show_percent=True, show_pos=True
267 ) as files:
268 # Use match_files to get proper scope matching including code patterns
269 results = match_files(configs, iter(files))
271 # Count files with and without scope matches
272 for path_str, path_match in results.paths.items():
273 if path_match.scopes:
274 num_matched += 1
275 else:
276 uncovered_files.append(path_str)
277 num_total += 1
279 # Also count files that weren't in the results (no scope matches at all)
280 for f in all_files:
281 if f not in results.paths:
282 uncovered_files.append(f)
283 num_total += 1
285 percentage = (num_matched / num_total) * 100
287 # Display coverage statistics
288 if num_matched == num_total:
289 click.secho(f"\n✓ {num_matched}/{num_total} files covered (100.0%)", fg="green")
290 else:
291 # Show uncovered files
292 if uncovered_files:
293 click.echo("\nUncovered files:")
294 for file in sorted(uncovered_files)[:10]: # Show first 10
295 click.echo(f" - {file}")
296 if len(uncovered_files) > 10:
297 click.echo(f" ...and {len(uncovered_files) - 10} more")
299 click.secho(
300 f"\n{num_matched}/{num_total} files covered ({percentage:.1f}%)",
301 fg="yellow",
302 )
304 if check_flag and num_matched != num_total:
305 sys.exit(1)
308# list - find open PRs, find status url and send json request (needs PA token)