2
0

workflow.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. """
  2. Workflow module.
  3. This module handles the main workflow logic for running coverage tests and processing results.
  4. """
  5. import os
  6. import re
  7. import sys
  8. import subprocess
  9. import traceback
  10. from .extraction import run_coverage, compare_coverage, extract_coverage
  11. from .github_api import generate_comment, post_comment, set_github_output
  12. from .util import log, file_exists, get_file_size, list_directory, run_command
  13. def is_valid_branch_name(branch_name: str) -> bool:
  14. """
  15. Validate a git branch name.
  16. Args:
  17. branch_name: Branch name to validate
  18. Returns:
  19. True if valid, False otherwise
  20. """
  21. # Check for common branch name patterns
  22. if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
  23. return False
  24. # Check for path traversal
  25. if '..' in branch_name:
  26. return False
  27. # Check for shell metacharacters
  28. if re.search(r'[;&|`$]', branch_name):
  29. return False
  30. return True
  31. def checkout_branch(branch_name: str) -> None:
  32. """
  33. Checkout a branch for testing.
  34. Args:
  35. branch_name: Branch name to checkout
  36. Raises:
  37. RuntimeError: If branch checkout fails
  38. ValueError: If branch name is invalid
  39. """
  40. if not is_valid_branch_name(branch_name):
  41. raise ValueError(f"Invalid branch name: {branch_name}")
  42. log(f"=== Checking out branch: {branch_name} ===")
  43. # Fetch the branch
  44. returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
  45. if returncode != 0:
  46. log(f"ERROR: Failed to fetch branch {branch_name}")
  47. log(f"Error details: {stderr}")
  48. raise RuntimeError(f"Git fetch failed: {stderr}")
  49. # Checkout the branch
  50. returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
  51. if returncode != 0:
  52. log(f"ERROR: Failed to checkout branch {branch_name}")
  53. log(f"Error details: {stderr}")
  54. raise RuntimeError(f"Git checkout failed: {stderr}")
  55. log(f"Successfully checked out branch: {branch_name}")
  56. def extract_extension_coverage_from_file(file_path):
  57. """Extract extension coverage from file when run_coverage returns 0."""
  58. if not file_exists(file_path):
  59. log(f"File {file_path} does not exist, cannot extract extension coverage")
  60. return 0.0
  61. file_size = get_file_size(file_path)
  62. if file_size == 0:
  63. log(f"File {file_path} is empty, cannot extract extension coverage")
  64. return 0.0
  65. log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
  66. with open(file_path, 'r') as f:
  67. content = f.read()
  68. # Extract the percentage from the "Lines" row in the coverage summary
  69. # Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
  70. lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
  71. if lines_match:
  72. coverage = float(lines_match.group(1))
  73. log(f"Found extension coverage in file: {coverage}%")
  74. return coverage
  75. return 0.0
  76. def extract_webview_coverage_from_file(file_path):
  77. """Extract webview coverage from file when run_coverage returns 0."""
  78. if not file_exists(file_path):
  79. log(f"File {file_path} does not exist, cannot extract webview coverage")
  80. return 0.0
  81. file_size = get_file_size(file_path)
  82. if file_size == 0:
  83. log(f"File {file_path} is empty, cannot extract webview coverage")
  84. return 0.0
  85. log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
  86. with open(file_path, 'r') as f:
  87. content = f.read()
  88. # Extract the percentage from the "% Lines" column in the "All files" row
  89. # Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
  90. all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
  91. if all_files_match:
  92. coverage = float(all_files_match.group(1))
  93. log(f"Found webview coverage in file: {coverage}%")
  94. return coverage
  95. return 0.0
  96. def run_extension_coverage(branch_name=None):
  97. """Run extension coverage tests and extract results."""
  98. prefix = 'base_' if branch_name else ''
  99. file_path = f"{prefix}extension_coverage.txt"
  100. # Run coverage tests
  101. ext_cov = run_coverage(
  102. ["xvfb-run", "-a", "npm", "run", "test:coverage"],
  103. file_path,
  104. "extension"
  105. )
  106. # If coverage is 0.0, try to extract from file directly
  107. if ext_cov == 0.0:
  108. ext_cov = extract_extension_coverage_from_file(file_path)
  109. return ext_cov
  110. def run_webview_coverage(branch_name=None):
  111. """Run webview coverage tests and extract results."""
  112. prefix = 'base_' if branch_name else ''
  113. file_path = f"{prefix}webview_coverage.txt"
  114. # Save current directory
  115. original_dir = os.getcwd()
  116. try:
  117. # Change to webview-ui directory
  118. os.chdir('webview-ui')
  119. # Install coverage dependency
  120. returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
  121. if returncode != 0:
  122. log(f"Failed to install coverage dependency: {stderr}")
  123. return 0.0
  124. # Run coverage tests from webview-ui directory
  125. web_cov = run_coverage(
  126. ["npm", "run", "test:coverage"],
  127. os.path.join('..', file_path),
  128. "webview"
  129. )
  130. finally:
  131. # Always change back to original directory
  132. os.chdir(original_dir)
  133. # If coverage is 0.0, try to extract from file directly
  134. if web_cov == 0.0:
  135. web_cov = extract_webview_coverage_from_file(file_path)
  136. return web_cov
  137. def run_branch_coverage(branch_name=None):
  138. """
  139. Run coverage tests for a branch.
  140. Args:
  141. branch_name: Name of the branch to checkout before running tests (optional)
  142. Returns:
  143. Tuple of (extension_coverage, webview_coverage)
  144. """
  145. # Checkout branch if specified
  146. if branch_name:
  147. checkout_branch(branch_name)
  148. # Run coverage tests
  149. log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
  150. # Run extension and webview coverage
  151. ext_cov = run_extension_coverage(branch_name)
  152. web_cov = run_webview_coverage(branch_name)
  153. return ext_cov, web_cov
  154. def find_potential_coverage_files():
  155. """Find potential coverage files in the current directory and webview-ui."""
  156. log("Searching for potential coverage files...")
  157. # Find files in current directory
  158. current_dir_files = list_directory('.')
  159. for name, size in current_dir_files:
  160. if 'coverage' in name.lower() and size != "DIR":
  161. log(f"Found potential coverage file: {name} (size: {size} bytes)")
  162. # Find files in webview-ui directory
  163. if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
  164. webview_files = list_directory('webview-ui')
  165. for name, size in webview_files:
  166. if 'coverage' in name.lower() and size != "DIR":
  167. log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
  168. else:
  169. log("webview-ui directory not found")
  170. def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
  171. base_web_cov, pr_web_cov, web_decreased, web_diff):
  172. """Generate warnings for coverage decreases."""
  173. if not (ext_decreased or web_decreased):
  174. return []
  175. warnings = [
  176. "Test coverage has decreased in this PR",
  177. f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
  178. f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
  179. ]
  180. # Additional warning for significant decrease (more than 1%)
  181. if ext_decreased and ext_diff > 1.0:
  182. warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
  183. if web_decreased and web_diff > 1.0:
  184. warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
  185. return warnings
  186. def output_warnings(warnings):
  187. """Output warnings to GitHub step summary and console."""
  188. if not warnings:
  189. return
  190. # Get the GitHub step summary file path from environment variable
  191. github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
  192. # Write to GitHub step summary if available
  193. if github_step_summary:
  194. with open(github_step_summary, 'a') as f:
  195. f.write("## Coverage Warnings\n\n")
  196. for warning in warnings:
  197. f.write(f"⚠️ {warning}\n\n")
  198. # Also output to console with ::warning:: syntax for backward compatibility
  199. for warning in warnings:
  200. log(f"::warning::{warning}")
  201. def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
  202. ext_decreased, ext_diff, web_decreased, web_diff):
  203. """Output results for GitHub Actions."""
  204. set_github_output("pr_extension_coverage", pr_ext_cov)
  205. set_github_output("pr_webview_coverage", pr_web_cov)
  206. set_github_output("base_extension_coverage", base_ext_cov)
  207. set_github_output("base_webview_coverage", base_web_cov)
  208. set_github_output("extension_decreased", str(ext_decreased).lower())
  209. set_github_output("extension_diff", ext_diff)
  210. set_github_output("webview_decreased", str(web_decreased).lower())
  211. set_github_output("webview_diff", web_diff)
  212. def extract_pr_coverage_from_artifacts():
  213. """
  214. Extract PR branch coverage from artifact files.
  215. Returns:
  216. Tuple of (extension_coverage, webview_coverage)
  217. Raises:
  218. SystemExit: If the coverage files don't exist
  219. """
  220. log("=== Extracting PR branch coverage from artifacts ===")
  221. # Check if the coverage files exist
  222. ext_file_path = "extension_coverage.txt"
  223. web_file_path = "webview-ui/webview_coverage.txt"
  224. # Extract extension coverage
  225. log(f"Extracting extension coverage from {ext_file_path}")
  226. if not file_exists(ext_file_path):
  227. error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
  228. log(error_msg)
  229. # List directory contents for debugging
  230. log("Current directory contents:")
  231. try:
  232. dir_contents = list_directory('.')
  233. for name, size in dir_contents:
  234. log(f" {name} - {size}\n")
  235. except Exception as e:
  236. log(f"Error listing directory: {e}")
  237. sys.exit(1) # Exit with error code to fail the workflow
  238. ext_cov = extract_extension_coverage_from_file(ext_file_path)
  239. log(f"PR extension coverage from artifact: {ext_cov}%")
  240. # Extract webview coverage
  241. log(f"Extracting webview coverage from {web_file_path}")
  242. if not file_exists(web_file_path):
  243. error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
  244. log(error_msg)
  245. # Check if the webview-ui directory exists
  246. if not os.path.exists('webview-ui'):
  247. log("ERROR: webview-ui directory not found")
  248. else:
  249. # List webview-ui directory contents for debugging
  250. log("webview-ui directory contents:")
  251. try:
  252. dir_contents = list_directory('webview-ui')
  253. for name, size in dir_contents:
  254. log(f" {name} - {size}")
  255. except Exception as e:
  256. log(f"Error listing directory: {e}")
  257. sys.exit(1) # Exit with error code to fail the workflow
  258. web_cov = extract_webview_coverage_from_file(web_file_path)
  259. log(f"PR webview coverage from artifact: {web_cov}%")
  260. return ext_cov, web_cov
  261. def process_coverage_workflow(args):
  262. """
  263. Process the entire coverage workflow.
  264. Args:
  265. args: Command line arguments
  266. """
  267. # Initialize all variables at the start
  268. pr_ext_cov = 0.0
  269. pr_web_cov = 0.0
  270. base_ext_cov = 0.0
  271. base_web_cov = 0.0
  272. ext_decreased = False
  273. ext_diff = 0.0
  274. web_decreased = False
  275. web_diff = 0.0
  276. try:
  277. # Validate branch name
  278. if not is_valid_branch_name(args.base_branch):
  279. raise ValueError(f"Invalid base branch name: {args.base_branch}")
  280. # Check if we're running in GitHub Actions
  281. is_github_actions = 'GITHUB_ACTIONS' in os.environ
  282. if is_github_actions:
  283. log("Running in GitHub Actions environment")
  284. # Extract PR branch coverage from artifacts (from test job)
  285. pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
  286. # Verify PR coverage values
  287. if pr_ext_cov == 0.0:
  288. log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
  289. find_potential_coverage_files()
  290. if pr_web_cov == 0.0:
  291. log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
  292. find_potential_coverage_files()
  293. # Run base branch coverage
  294. log(f"=== Running base branch coverage for {args.base_branch} ===")
  295. base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
  296. # Verify base coverage values
  297. if base_ext_cov == 0.0:
  298. log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
  299. if base_web_cov == 0.0:
  300. log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
  301. # Compare coverage
  302. log("=== Comparing extension coverage ===")
  303. ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
  304. log("=== Comparing webview coverage ===")
  305. web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
  306. # Print summary of coverage values
  307. log("\n=== Coverage Summary ===")
  308. log(f"PR extension coverage: {pr_ext_cov}%")
  309. log(f"Base extension coverage: {base_ext_cov}%")
  310. log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
  311. log(f"PR webview coverage: {pr_web_cov}%")
  312. log(f"Base webview coverage: {base_web_cov}%")
  313. log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
  314. # Generate and output warnings
  315. warnings = generate_warnings(
  316. base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
  317. base_web_cov, pr_web_cov, web_decreased, web_diff
  318. )
  319. output_warnings(warnings)
  320. # Generate comment
  321. log("=== Generating comment ===")
  322. comment = generate_comment(
  323. base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
  324. base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
  325. )
  326. # Save comment to file
  327. with open("coverage_comment.md", "w") as f:
  328. f.write(comment)
  329. # Post comment if PR number is provided
  330. if args.pr_number:
  331. log(f"=== Posting comment to PR #{args.pr_number} ===")
  332. post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
  333. # Output results for GitHub Actions
  334. output_github_results(
  335. pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
  336. ext_decreased, ext_diff, web_decreased, web_diff
  337. )
  338. except Exception as e:
  339. log(f"ERROR in process_coverage_workflow: {e}")
  340. traceback.print_exc()
  341. # Try to output results even if there was an error
  342. try:
  343. output_github_results(
  344. pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
  345. ext_decreased, ext_diff, web_decreased, web_diff
  346. )
  347. except Exception as e2:
  348. log(f"ERROR outputting GitHub results: {e2}")