Coverage for src/pullapprove/cli.py: 0%
150 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-25 12:24 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-25 12:24 -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 if not configs and not quiet:
111 click.secho("No CODEREVIEW.toml files found.", fg="red")
112 sys.exit(1)
114 return configs
117@cli.command()
118@click.option("--changed", is_flag=True, help="Show only changed files")
119@click.option("--staged", is_flag=True, help="Show only staged files")
120@click.option("--diff", is_flag=True, help="Show diff content with matches")
121@click.option(
122 "--by-scope", is_flag=True, help="Organize output by scope instead of by path"
123)
124@click.option(
125 "--scope",
126 multiple=True,
127 help="Filter to show only files matching these scopes (can be used multiple times)",
128)
129@click.argument("paths", nargs=-1, type=click.Path())
130@click.pass_context
131def match(
132 ctx: click.Context,
133 changed: bool,
134 staged: bool,
135 diff: bool,
136 by_scope: bool,
137 scope: tuple[str, ...],
138 paths: tuple[str, ...],
139) -> None:
140 """
141 Show files and their matching scopes
143 If PATHS are provided, only those specific paths will be matched.
144 Directories will be recursively expanded to include all files within them.
145 Otherwise, all files in the repository will be matched.
146 """
147 configs = ctx.invoke(check, quiet=True).compiled()
149 if not configs:
150 click.secho("No valid configurations found.", fg="red")
151 raise click.Abort("No configurations to check.")
153 if paths:
154 # When specific paths are provided, match only those paths
155 if diff or staged or changed:
156 click.secho(
157 "Cannot use --diff, --staged, or --changed with specific paths.",
158 fg="red",
159 )
160 raise click.Abort("Conflicting options.")
162 # Get all git-tracked files and filter by provided paths
163 all_git_files = set(git.git_ls_files(Path(".")))
164 expanded_paths = []
166 for path_str in paths:
167 path = Path(path_str)
168 if path.is_dir():
169 # Filter git files that are within this directory
170 dir_prefix = str(path) + "/"
171 for git_file in all_git_files:
172 if git_file.startswith(dir_prefix) or git_file == str(path):
173 expanded_paths.append(git_file)
174 elif path.is_file() or str(path) in all_git_files:
175 # Include if it's a git-tracked file
176 if str(path) in all_git_files:
177 expanded_paths.append(str(path))
178 else:
179 click.secho(f"File not tracked by git: {path}", fg="yellow")
180 else:
181 click.secho(
182 f"Path does not exist or not tracked by git: {path}", fg="yellow"
183 )
185 matches = match_files(configs, iter(expanded_paths))
186 all_files = expanded_paths
187 elif diff or staged:
188 # Use git diff for these options
189 diff_args = []
190 if staged:
191 diff_args.append("--staged")
193 diff_stream = git.git_diff_stream(Path("."), *diff_args)
194 diff_results = match_diff(configs, diff_stream)
195 matches = diff_results.matches
196 # For diff mode, we only show files in the diff
197 all_files = None
198 elif changed:
199 iterator = git.git_ls_changes(Path("."))
200 matches = match_files(configs, iterator)
201 # For changed mode, we only show changed files
202 all_files = None
203 else:
204 # For normal mode, show all files to see gaps
205 iterator = git.git_ls_files(Path("."))
206 matches = match_files(configs, iterator)
207 # Get all files again for the printer
208 all_files = list(git.git_ls_files(Path(".")))
210 printer = MatchesPrinter(matches, all_files=all_files)
211 if by_scope:
212 printer.print_by_scope(scope_filter=scope)
213 else:
214 printer.print_by_path(scope_filter=scope)
217@cli.command()
218@click.option(
219 "--check",
220 "check_flag",
221 is_flag=True,
222 help="Exit with non-zero status if coverage is incomplete",
223)
224@click.argument("path", type=click.Path(exists=True), default=".")
225@click.pass_context
226def coverage(ctx: click.Context, path: str, check_flag: bool) -> None:
227 """
228 Calculate file coverage for review scopes
229 """
230 configs = ctx.invoke(check, quiet=True).compiled()
232 num_matched = 0
233 num_total = 0
234 uncovered_files = []
236 # First, get all files to know the total count for progress bar
237 all_files = list(git.git_ls_files(Path(path)))
239 if not all_files:
240 click.echo("No files found")
241 return
243 # Process files with progress bar
244 with click.progressbar(
245 all_files, label="Analyzing coverage", show_percent=True, show_pos=True
246 ) as files:
247 # Use match_files to get proper scope matching including code patterns
248 results = match_files(configs, iter(files))
250 # Count files with and without scope matches
251 for path_str, path_match in results.paths.items():
252 if path_match.scopes:
253 num_matched += 1
254 else:
255 uncovered_files.append(path_str)
256 num_total += 1
258 # Also count files that weren't in the results (no scope matches at all)
259 for f in all_files:
260 if f not in results.paths:
261 uncovered_files.append(f)
262 num_total += 1
264 percentage = (num_matched / num_total) * 100
266 # Display coverage statistics
267 if num_matched == num_total:
268 click.secho(f"\n✓ {num_matched}/{num_total} files covered (100.0%)", fg="green")
269 else:
270 # Show uncovered files
271 if uncovered_files:
272 click.echo("\nUncovered files:")
273 for file in sorted(uncovered_files)[:10]: # Show first 10
274 click.echo(f" - {file}")
275 if len(uncovered_files) > 10:
276 click.echo(f" ...and {len(uncovered_files) - 10} more")
278 click.secho(
279 f"\n{num_matched}/{num_total} files covered ({percentage:.1f}%)",
280 fg="yellow",
281 )
283 if check_flag and num_matched != num_total:
284 sys.exit(1)
287# list - find open PRs, find status url and send json request (needs PA token)