Skip to content

Commit bc814fd

Browse files
authored
feat(ci): monitor test weights and update automatically (#15126)
1 parent 0f79f62 commit bc814fd

File tree

4 files changed

+908
-0
lines changed

4 files changed

+908
-0
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare test weights and generate PR description with change analysis.
4+
5+
This script compares old and new test weight files to:
6+
1. Calculate percentage changes in total time
7+
2. Identify tests with significant duration changes (>10%)
8+
3. Find new and removed tests
9+
4. Generate recommendations for batch count adjustments if total time changes >20%
10+
"""
11+
12+
import argparse
13+
import json
14+
import sys
15+
from pathlib import Path
16+
from typing import Dict, List, Tuple
17+
18+
19+
def load_weights(file_path: str, test_id_key: str) -> Dict[str, float]:
20+
"""Load test weights from JSON file into a dict."""
21+
if not Path(file_path).exists():
22+
return {}
23+
24+
with open(file_path) as f:
25+
data = json.load(f)
26+
27+
return {
28+
item[test_id_key]: float(item['duration'].rstrip('s'))
29+
for item in data
30+
}
31+
32+
33+
def calculate_changes(old_weights: Dict[str, float], new_weights: Dict[str, float]) -> Dict:
34+
"""Calculate comprehensive change statistics."""
35+
36+
# New and removed tests (calculate first to exclude from significant changes)
37+
new_test_ids = set(new_weights.keys()) - set(old_weights.keys())
38+
removed_test_ids = set(old_weights.keys()) - set(new_weights.keys())
39+
40+
new_tests = {test: new_weights[test] for test in new_test_ids}
41+
removed_tests = {test: old_weights[test] for test in removed_test_ids}
42+
43+
# Overall stats
44+
old_total = sum(old_weights.values())
45+
new_total = sum(new_weights.values())
46+
total_change_pct = ((new_total - old_total) / old_total * 100) if old_total > 0 else 0
47+
48+
# Individual test changes (ONLY for tests that exist in both old and new)
49+
significant_changes = []
50+
for test_id, new_time in new_weights.items():
51+
# Skip new tests - they shouldn't appear in significant changes
52+
if test_id in old_weights:
53+
old_time = old_weights[test_id]
54+
diff = new_time - old_time
55+
pct_change = (diff / old_time * 100) if old_time > 0 else 0
56+
57+
# Only report significant changes for tests with meaningful durations (>5s)
58+
# This filters out noise from very fast tests
59+
if (abs(pct_change) > 10 or abs(diff) > 10) and (old_time >= 5.0 or new_time >= 5.0):
60+
significant_changes.append({
61+
'test': test_id,
62+
'old': old_time,
63+
'new': new_time,
64+
'diff': diff,
65+
'pct': pct_change
66+
})
67+
68+
# Sort by absolute percentage change
69+
significant_changes.sort(key=lambda x: abs(x['pct']), reverse=True)
70+
71+
return {
72+
'old_total': old_total,
73+
'new_total': new_total,
74+
'total_change_pct': total_change_pct,
75+
'old_count': len(old_weights),
76+
'new_count': len(new_weights),
77+
'significant_changes': significant_changes,
78+
'new_tests': new_tests,
79+
'removed_tests': removed_tests,
80+
'new_tests_total': sum(new_tests.values()),
81+
'removed_tests_total': sum(removed_tests.values())
82+
}
83+
84+
85+
def generate_pr_body(cypress_changes: Dict, pytest_changes: Dict) -> str:
86+
"""Generate markdown PR body with change analysis."""
87+
88+
lines = []
89+
lines.append("## 🤖 Automated Test Weight Update")
90+
lines.append("")
91+
lines.append("This PR updates test weights based on recent CI runs to improve batch balancing.")
92+
lines.append("")
93+
94+
# Overall summary
95+
lines.append("## 📊 Summary")
96+
lines.append("")
97+
lines.append("| Test Type | Old Total | New Total | Change | # Tests |")
98+
lines.append("|-----------|-----------|-----------|--------|---------|")
99+
100+
for name, changes in [("Cypress", cypress_changes), ("Pytest", pytest_changes)]:
101+
old_min = changes['old_total'] / 60
102+
new_min = changes['new_total'] / 60
103+
change_sign = "+" if changes['total_change_pct'] > 0 else ""
104+
lines.append(
105+
f"| {name} | {old_min:.1f} min | {new_min:.1f} min | "
106+
f"{change_sign}{changes['total_change_pct']:.1f}% | "
107+
f"{changes['old_count']}{changes['new_count']} |"
108+
)
109+
110+
lines.append("")
111+
112+
# Warnings for large changes
113+
warnings = []
114+
for name, changes in [("Cypress", cypress_changes), ("Pytest", pytest_changes)]:
115+
if abs(changes['total_change_pct']) > 20:
116+
warnings.append(
117+
f"⚠️ **{name} total time changed by {changes['total_change_pct']:+.1f}%** - "
118+
f"Consider reviewing batch count configuration!"
119+
)
120+
121+
if warnings:
122+
lines.append("## ⚠️ Warnings")
123+
lines.append("")
124+
for warning in warnings:
125+
lines.append(warning)
126+
lines.append("")
127+
lines.append("<details>")
128+
lines.append("<summary>Batch count recommendations</summary>")
129+
lines.append("")
130+
lines.append("Current configuration:")
131+
lines.append("- Cypress: 11 batches (Depot) / 5 batches (GitHub runners)")
132+
lines.append("- Pytest: 6 batches (Depot) / 3 batches (GitHub runners)")
133+
lines.append("")
134+
lines.append("If total time increased >20%, consider increasing batch count to maintain CI speed.")
135+
lines.append("If total time decreased >20%, consider decreasing batch count to save runner costs.")
136+
lines.append("")
137+
lines.append("Update batch counts in `.github/workflows/docker-unified.yml`")
138+
lines.append("</details>")
139+
lines.append("")
140+
141+
# Significant changes
142+
def format_significant_changes(changes: Dict, name: str, max_display: int = 15):
143+
if not changes['significant_changes']:
144+
return []
145+
146+
section = []
147+
section.append(f"## 🔍 {name} - Significant Changes (>10% or >10s)")
148+
section.append("")
149+
section.append("<details>")
150+
section.append(f"<summary>{len(changes['significant_changes'])} tests with significant duration changes</summary>")
151+
section.append("")
152+
153+
for item in changes['significant_changes'][:max_display]:
154+
sign = "+" if item['diff'] > 0 else ""
155+
emoji = "🔴" if item['diff'] > 0 else "🟢"
156+
section.append(f"**{emoji} `{item['test']}`**")
157+
section.append(f"- Old: {item['old']:.1f}s → New: {item['new']:.1f}s ({sign}{item['diff']:.1f}s, {sign}{item['pct']:.1f}%)")
158+
section.append("")
159+
160+
if len(changes['significant_changes']) > max_display:
161+
section.append(f"*... and {len(changes['significant_changes']) - max_display} more*")
162+
section.append("")
163+
164+
section.append("</details>")
165+
section.append("")
166+
return section
167+
168+
lines.extend(format_significant_changes(cypress_changes, "Cypress"))
169+
lines.extend(format_significant_changes(pytest_changes, "Pytest"))
170+
171+
# New and removed tests - show summary instead of listing all
172+
def format_test_changes(changes: Dict, name: str):
173+
new_tests = changes['new_tests']
174+
removed_tests = changes['removed_tests']
175+
new_tests_total = changes['new_tests_total']
176+
removed_tests_total = changes['removed_tests_total']
177+
178+
if not new_tests and not removed_tests:
179+
return []
180+
181+
section = []
182+
section.append(f"## ✨ {name} - Test Changes")
183+
section.append("")
184+
185+
if new_tests:
186+
section.append(f"**➕ Added: {len(new_tests)} tests** ({new_tests_total/60:.1f} min total)")
187+
section.append("<details>")
188+
section.append("<summary>View new tests</summary>")
189+
section.append("")
190+
for test, duration in sorted(list(new_tests.items())[:20]):
191+
section.append(f"- `{test}`: {duration:.1f}s")
192+
if len(new_tests) > 20:
193+
section.append(f"- *... and {len(new_tests) - 20} more*")
194+
section.append("")
195+
section.append("</details>")
196+
section.append("")
197+
198+
if removed_tests:
199+
section.append(f"**➖ Removed: {len(removed_tests)} tests** ({removed_tests_total/60:.1f} min total)")
200+
section.append("<details>")
201+
section.append("<summary>View removed tests</summary>")
202+
section.append("")
203+
for test, duration in sorted(list(removed_tests.items())[:20]):
204+
section.append(f"- `{test}`: {duration:.1f}s")
205+
if len(removed_tests) > 20:
206+
section.append(f"- *... and {len(removed_tests) - 20} more*")
207+
section.append("")
208+
section.append("</details>")
209+
section.append("")
210+
211+
return section
212+
213+
lines.extend(format_test_changes(cypress_changes, "Cypress"))
214+
lines.extend(format_test_changes(pytest_changes, "Pytest"))
215+
216+
# Footer
217+
lines.append("---")
218+
lines.append("")
219+
lines.append("*Generated by automated test weight update workflow*")
220+
lines.append("")
221+
222+
return "\n".join(lines)
223+
224+
225+
def main():
226+
parser = argparse.ArgumentParser(description="Compare test weights and generate PR description")
227+
parser.add_argument("--old-cypress", required=True, help="Path to old Cypress weights JSON")
228+
parser.add_argument("--new-cypress", required=True, help="Path to new Cypress weights JSON")
229+
parser.add_argument("--old-pytest", required=True, help="Path to old Pytest weights JSON")
230+
parser.add_argument("--new-pytest", required=True, help="Path to new Pytest weights JSON")
231+
parser.add_argument("--output", required=True, help="Output file for PR body markdown")
232+
parser.add_argument("--threshold", type=float, default=5.0,
233+
help="Minimum total change percentage to trigger PR (default: 5.0)")
234+
235+
args = parser.parse_args()
236+
237+
# Load weights
238+
old_cypress = load_weights(args.old_cypress, 'filePath')
239+
new_cypress = load_weights(args.new_cypress, 'filePath')
240+
old_pytest = load_weights(args.old_pytest, 'testId')
241+
new_pytest = load_weights(args.new_pytest, 'testId')
242+
243+
# Calculate changes
244+
cypress_changes = calculate_changes(old_cypress, new_cypress)
245+
pytest_changes = calculate_changes(old_pytest, new_pytest)
246+
247+
# Check if changes exceed threshold
248+
max_change = max(abs(cypress_changes['total_change_pct']), abs(pytest_changes['total_change_pct']))
249+
250+
print(f"Cypress total change: {cypress_changes['total_change_pct']:+.2f}%")
251+
print(f"Pytest total change: {pytest_changes['total_change_pct']:+.2f}%")
252+
print(f"Max change: {max_change:.2f}%")
253+
print(f"Threshold: {args.threshold}%")
254+
255+
if max_change < args.threshold:
256+
print(f"\n✓ Changes below threshold ({args.threshold}%). No PR needed.")
257+
with open(args.output, 'w') as f:
258+
f.write("")
259+
sys.exit(0)
260+
261+
print(f"\n✓ Changes exceed threshold. Generating PR body...")
262+
263+
# Generate PR body
264+
pr_body = generate_pr_body(cypress_changes, pytest_changes)
265+
266+
# Write to output file
267+
with open(args.output, 'w') as f:
268+
f.write(pr_body)
269+
270+
print(f"✓ PR body written to {args.output}")
271+
print(f"✓ Significant changes: Cypress={len(cypress_changes['significant_changes'])}, Pytest={len(pytest_changes['significant_changes'])}")
272+
273+
sys.exit(0)
274+
275+
276+
if __name__ == "__main__":
277+
main()

0 commit comments

Comments
 (0)