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

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

24 

25 

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

33 

34# # What might we be reviewing? 

35# # - PR number / url 

36# # - branch 

37# # - diff 

38 

39# # This is an alias for files --changed 

40# ctx.invoke(files, changed=True) 

41 

42 

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) 

51 

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 

56 

57 contents = """ 

58 [[scopes]] 

59 name = "default" 

60 paths = ["**/*"] 

61 request = 1 

62 require = 1 

63 reviewers = ["<YOU>"] 

64 

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

74 

75 

76@cli.command() 

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

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

79 """ 

80 Validate configuration files 

81 """ 

82 

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 ) 

100 

101 errors = {} 

102 configs = ConfigModels(root={}) 

103 

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 

108 

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 ) 

115 

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

121 

122 errors[config_path] = e 

123 

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

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

126 print(error) 

127 

128 if errors: 

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

130 

131 if not configs and not quiet: 

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

133 sys.exit(1) 

134 

135 return configs 

136 

137 

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 

163 

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) 

169 

170 if not configs: 

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

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

173 

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

182 

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

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

185 expanded_paths = [] 

186 

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 ) 

205 

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

213 

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

230 

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) 

236 

237 

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) 

252 

253 num_matched = 0 

254 num_total = 0 

255 uncovered_files = [] 

256 

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

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

259 

260 if not all_files: 

261 click.echo("No files found") 

262 return 

263 

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

270 

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 

278 

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 

284 

285 percentage = (num_matched / num_total) * 100 

286 

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

298 

299 click.secho( 

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

301 fg="yellow", 

302 ) 

303 

304 if check_flag and num_matched != num_total: 

305 sys.exit(1) 

306 

307 

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