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

1import os 

2import sys 

3from pathlib import Path 

4from textwrap import dedent 

5 

6import click 

7from pydantic import ValidationError 

8 

9from . import git 

10from .config import CONFIG_FILENAME, CONFIG_FILENAME_PREFIX, ConfigModel, ConfigModels 

11from .matches import match_diff, match_files 

12from .printer import MatchesPrinter 

13 

14 

15@click.group() 

16@click.version_option(package_name="pullapprove") 

17@click.pass_context 

18def cli(ctx: click.Context) -> None: 

19 pass 

20 

21 

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) 

30 

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 

35 

36 contents = """ 

37 [[scopes]] 

38 name = "default" 

39 paths = ["**/*"] 

40 request = 1 

41 require = 1 

42 reviewers = ["<YOU>"] 

43 

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}") 

53 

54 

55@cli.command() 

56@click.option("--quiet", is_flag=True) 

57def check(quiet: bool) -> ConfigModels: 

58 """ 

59 Validate configuration files 

60 """ 

61 

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 ) 

79 

80 errors = {} 

81 configs = ConfigModels(root={}) 

82 

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 

87 

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 ) 

94 

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") 

100 

101 errors[config_path] = e 

102 

103 for path, error in errors.items(): 

104 click.secho(str(path), fg="red") 

105 print(error) 

106 

107 if errors: 

108 raise click.Abort("Configuration validation failed.") 

109 

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.") 

120 

121 if not configs and not quiet: 

122 click.secho("No CODEREVIEW.toml files found.", fg="red") 

123 sys.exit(1) 

124 

125 return configs 

126 

127 

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 

153 

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() 

159 

160 if not configs: 

161 click.secho("No valid configurations found.", fg="red") 

162 raise click.Abort("No configurations to check.") 

163 

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.") 

172 

173 # Get all git-tracked files and filter by provided paths 

174 all_git_files = set(git.git_ls_files(Path("."))) 

175 expanded_paths = [] 

176 

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 ) 

195 

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") 

203 

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("."))) 

220 

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) 

226 

227 

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() 

242 

243 num_matched = 0 

244 num_total = 0 

245 uncovered_files = [] 

246 

247 # First, get all files to know the total count for progress bar 

248 all_files = list(git.git_ls_files(Path(path))) 

249 

250 if not all_files: 

251 click.echo("No files found") 

252 return 

253 

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)) 

260 

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 

268 

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 

274 

275 percentage = (num_matched / num_total) * 100 

276 

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") 

288 

289 click.secho( 

290 f"\n{num_matched}/{num_total} files covered ({percentage:.1f}%)", 

291 fg="yellow", 

292 ) 

293 

294 if check_flag and num_matched != num_total: 

295 sys.exit(1) 

296 

297 

298# list - find open PRs, find status url and send json request (needs PA token)