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

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 if not configs and not quiet: 

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

112 sys.exit(1) 

113 

114 return configs 

115 

116 

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 

142 

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

148 

149 if not configs: 

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

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

152 

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

161 

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

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

164 expanded_paths = [] 

165 

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 ) 

184 

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

192 

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

209 

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) 

215 

216 

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

231 

232 num_matched = 0 

233 num_total = 0 

234 uncovered_files = [] 

235 

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

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

238 

239 if not all_files: 

240 click.echo("No files found") 

241 return 

242 

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

249 

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 

257 

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 

263 

264 percentage = (num_matched / num_total) * 100 

265 

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

277 

278 click.secho( 

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

280 fg="yellow", 

281 ) 

282 

283 if check_flag and num_matched != num_total: 

284 sys.exit(1) 

285 

286 

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