-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
367 lines (317 loc) · 17.3 KB
/
Copy pathgenerate-rc-test-plan.yml
File metadata and controls
367 lines (317 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
name: Generate RC Test Plan
# Trigger when Bitrise posts "RC Builds Ready for Testing" comment
on:
issue_comment:
types: [created]
jobs:
generate-test-plan:
name: Generate AI Test Plan
# Only run when:
# 1. Comment is on a PR (not an issue)
# 2. Comment contains "RC Builds Ready for Testing"
# 3. Comment is from github-actions bot (Bitrise posts via this)
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, 'RC Builds Ready for Testing') &&
github.event.comment.user.login == 'github-actions[bot]'
runs-on: ubuntu-latest
environment: release-ci
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
issues: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_CLAUDE_API_KEY: ${{ secrets.E2E_CLAUDE_API_KEY }}
E2E_OPENAI_API_KEY: ${{ secrets.E2E_OPENAI_API_KEY }}
E2E_GEMINI_API_KEY: ${{ secrets.E2E_GEMINI_API_KEY }}
PR_NUMBER: ${{ github.event.issue.number }}
steps:
- name: Check if release PR
id: check-release
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: process.env.PR_NUMBER
});
const isRelease = pr.data.head.ref.startsWith('release/');
console.log(`PR branch: ${pr.data.head.ref}, isRelease: ${isRelease}`);
if (!isRelease) {
console.log('Not a release PR, skipping test plan generation');
return;
}
// Extract version from branch name (e.g., release/7.70.0 -> 7.70.0)
// Sanitize to prevent shell injection - only allow semver chars
const rawVersion = pr.data.head.ref.replace('release/', '');
const version = rawVersion.replace(/[^0-9.]/g, '');
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
console.log(`Invalid version format: ${rawVersion}`);
return;
}
core.setOutput('version', version);
core.setOutput('is_release', 'true');
core.setOutput('pr_title', pr.data.title);
- name: Checkout repository
if: steps.check-release.outputs.is_release == 'true'
uses: actions/checkout@v4
- name: Setup Node.js
if: steps.check-release.outputs.is_release == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
if: steps.check-release.outputs.is_release == 'true'
run: yarn install --frozen-lockfile
- name: Extract build number from comment
if: steps.check-release.outputs.is_release == 'true'
id: extract-build
uses: actions/github-script@v7
with:
script: |
const comment = context.payload.comment.body;
// Match build number from "RC X.Y.Z (BUILD)" pattern specifically
// e.g., "RC 7.65.0 (4025)" captures "4025"
const buildMatch = comment.match(/RC\s+\d+\.\d+\.\d+\s*\((\d+)\)/i);
if (buildMatch) {
const buildNumber = buildMatch[1];
console.log(`Extracted build number: ${buildNumber}`);
core.setOutput('build_number', buildNumber);
} else {
console.log('Could not extract build number from comment');
core.setOutput('build_number', '');
}
- name: Generate test plan
if: steps.check-release.outputs.is_release == 'true'
id: generate
run: |
VERSION="${{ steps.check-release.outputs.version }}"
RAW_BUILD="${{ steps.extract-build.outputs.build_number }}"
# Sanitize BUILD to only allow digits
BUILD=$(echo "$RAW_BUILD" | tr -cd '0-9')
echo "Generating test plan for version: $VERSION, build: $BUILD"
# Sanitize PR_NUMBER to only allow digits
PR_NUM=$(echo "${{ env.PR_NUMBER }}" | tr -cd '0-9')
# Run the analyzer
if node -r esbuild-register tests/tools/e2e-ai-analyzer \
--mode generate-test-plan \
--pr "$PR_NUM" \
--auto-ff \
-v "$VERSION"; then
echo "test_plan_generated=true" >> "${GITHUB_OUTPUT}"
else
echo "Warning: Test plan generation failed"
echo "test_plan_generated=false" >> "${GITHUB_OUTPUT}"
fi
- name: Generate HTML viewer
if: steps.generate.outputs.test_plan_generated == 'true'
run: |
VERSION="${{ steps.check-release.outputs.version }}"
BUILD="${{ steps.extract-build.outputs.build_number }}"
# Create test-plans directory
mkdir -p test-plans
# Move JSON
mv release-test-plan.json "test-plans/test-plan-${VERSION}.json"
# Generate HTML viewer
node -e "
const fs = require('fs');
const plan = JSON.parse(fs.readFileSync('test-plans/test-plan-${VERSION}.json', 'utf8'));
// Escape HTML to prevent XSS from LLM-generated content
const esc = (s) => (s == null ? '' : String(s)).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');
const html = \`<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<title>RC \${esc(plan.version) || '${VERSION}'} Test Plan</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 900px; margin: 0 auto; padding: 20px;
background: #f5f5f5; color: #333;
}
h1 { color: #1a1a1a; border-bottom: 3px solid #037dd6; padding-bottom: 10px; }
h2 { color: #037dd6; margin-top: 30px; }
h3 { color: #444; }
.summary { background: #fff; padding: 20px; border-radius: 8px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; }
.stat { text-align: center; padding: 15px; background: #f8f9fa; border-radius: 6px; }
.stat-value { font-size: 24px; font-weight: bold; color: #037dd6; }
.stat-label { font-size: 12px; color: #666; margin-top: 5px; }
.risk-high { border-left: 4px solid #d73a49; }
.risk-medium { border-left: 4px solid #f9a825; }
.scenario { background: #fff; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.scenario h3 { margin-top: 0; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.badge-high { background: #ffeef0; color: #d73a49; }
.badge-medium { background: #fff8e1; color: #f57c00; }
.steps { background: #f8f9fa; padding: 15px; border-radius: 6px; margin-top: 10px; }
.steps li { margin: 8px 0; }
.preconditions { background: #e3f2fd; padding: 10px 15px; border-radius: 6px; margin: 10px 0; }
.outcomes { background: #e8f5e9; padding: 10px 15px; border-radius: 6px; margin: 10px 0; }
.executive { background: linear-gradient(135deg, #037dd6 0%, #0260a8 100%); color: white; padding: 25px; border-radius: 8px; margin: 20px 0; }
.executive h2 { color: white; margin-top: 0; }
.teams { display: flex; flex-wrap: wrap; gap: 8px; margin: 15px 0; }
.team { background: #e3f2fd; padding: 5px 12px; border-radius: 20px; font-size: 13px; }
.footer { text-align: center; margin-top: 40px; padding: 20px; color: #666; font-size: 13px; }
a { color: #037dd6; }
</style>
</head>
<body>
<h1>🧪 RC \${esc(plan.version) || '${VERSION}'} Test Plan</h1>
<p>Build: \${plan.buildNumber || '${BUILD}'} | Generated: \${new Date(plan.generatedAt).toLocaleString()}</p>
\${plan.executiveSummary ? \`
<div class=\"executive\">
<h2>📊 Executive Summary</h2>
<p><strong>\${esc(plan.executiveSummary.releaseFocus)}</strong></p>
<p><strong>Key Changes:</strong></p>
<ul>\${plan.executiveSummary.keyChanges.map(c => '<li>' + esc(c) + '</li>').join('')}</ul>
<p><strong>Risk Level:</strong> \${esc(plan.executiveSummary.overallRisk).toUpperCase()}</p>
<p><strong>Recommendation:</strong> \${esc(plan.executiveSummary.recommendation)}</p>
</div>
\` : ''}
<div class=\"summary\">
<h2>📈 Summary</h2>
<div class=\"summary-grid\">
<div class=\"stat\"><div class=\"stat-value\">\${plan.summary?.releaseRiskScore || '0/100'}</div><div class=\"stat-label\">Risk Score</div></div>
<div class=\"stat\"><div class=\"stat-value\">\${plan.summary?.totalFiles || plan.summary?.totalFilesChanged || 0}</div><div class=\"stat-label\">Files Changed</div></div>
<div class=\"stat\"><div class=\"stat-value\">\${plan.summary?.highImpactFiles || 0}</div><div class=\"stat-label\">High Impact</div></div>
<div class=\"stat\"><div class=\"stat-value\">\${plan.summary?.highRiskCount || plan.summary?.highRiskScenarios || 0}</div><div class=\"stat-label\">High Risk</div></div>
<div class=\"stat\"><div class=\"stat-value\">\${plan.summary?.mediumRiskCount || plan.summary?.mediumRiskScenarios || 0}</div><div class=\"stat-label\">Medium Risk</div></div>
</div>
</div>
\${(plan.signOffs?.needsAttention?.length || plan.teamsNeedingSignOff?.length) ? \`
<h2>👥 Teams Needing Sign-off</h2>
<div class=\"teams\">\${(plan.signOffs?.needsAttention || plan.teamsNeedingSignOff || []).map(t => '<span class=\"team\">⏳ ' + esc(t) + '</span>').join('')}</div>
\` : ''}
\${plan.testScenarios?.cherryPickScenarios?.length ? \`
<h2>🍒 Cherry-Pick Scenarios</h2>
\${plan.testScenarios.cherryPickScenarios.map((s, i) => \`
<div class=\"scenario risk-high\">
<h3>\${i + 1}. \${esc(s.area)} <span class=\"badge badge-high\">CHERRY-PICK</span></h3>
<p><strong>Why:</strong> \${esc(s.whyThisMatters)}</p>
<div class=\"steps\"><strong>Test Steps:</strong><ol>\${(s.testSteps || []).map(step => '<li>' + esc(step) + '</li>').join('')}</ol></div>
</div>
\`).join('')}
\` : ''}
<h2>🔴 High Risk Areas</h2>
\${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'high').map((s, i) => \`
<div class=\"scenario risk-high\">
<h3>\${i + 1}. \${esc(s.area)} <span class=\"badge badge-high\">HIGH</span></h3>
<p><strong>Why:</strong> \${esc(s.whyThisMatters)}</p>
\${s.preconditions?.length ? '<div class=\"preconditions\"><strong>Preconditions:</strong><ul>' + s.preconditions.map(p => '<li>' + esc(p) + '</li>').join('') + '</ul></div>' : ''}
<div class=\"steps\"><strong>Test Steps:</strong><ol>\${(s.testSteps || []).map(step => '<li>' + esc(step) + '</li>').join('')}</ol></div>
\${s.expectedOutcomes?.length ? '<div class=\"outcomes\"><strong>Expected Outcomes:</strong><ul>' + s.expectedOutcomes.map(o => '<li>✓ ' + esc(o) + '</li>').join('') + '</ul></div>' : ''}
</div>
\`).join('')}
<h2>🟡 Medium Risk Areas</h2>
\${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'medium').map((s, i) => \`
<div class=\"scenario risk-medium\">
<h3>\${i + 1}. \${esc(s.area)} <span class=\"badge badge-medium\">MEDIUM</span></h3>
<p><strong>Why:</strong> \${esc(s.whyThisMatters)}</p>
\${s.preconditions?.length ? '<div class=\"preconditions\"><strong>Preconditions:</strong><ul>' + s.preconditions.map(p => '<li>' + esc(p) + '</li>').join('') + '</ul></div>' : ''}
<div class=\"steps\"><strong>Test Steps:</strong><ol>\${(s.testSteps || []).map(step => '<li>' + esc(step) + '</li>').join('')}</ol></div>
\${s.expectedOutcomes?.length ? '<div class=\"outcomes\"><strong>Expected Outcomes:</strong><ul>' + s.expectedOutcomes.map(o => '<li>✓ ' + esc(o) + '</li>').join('') + '</ul></div>' : ''}
</div>
\`).join('')}
<div class=\"footer\">
<p>Generated by AI Test Plan Analyzer | <a href=\"https://github.com/MetaMask/metamask-mobile\">MetaMask Mobile</a></p>
<p><a href=\"test-plan-${VERSION}.json\">Download JSON</a></p>
</div>
</body>
</html>\`;
fs.writeFileSync('test-plans/test-plan-${VERSION}.html', html);
console.log('Generated HTML viewer');
"
- name: Deploy to GitHub Pages
if: steps.generate.outputs.test_plan_generated == 'true'
run: |
VERSION="${{ steps.check-release.outputs.version }}"
# Save generated files to temp before switching branches
cp test-plans/test-plan-${VERSION}.json /tmp/
cp test-plans/test-plan-${VERSION}.html /tmp/
# Clean up to avoid conflicts when switching branches
rm -rf test-plans
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Fetch gh-pages branch or create it
git fetch origin gh-pages:gh-pages 2>/dev/null || echo "gh-pages doesn't exist yet"
# Switch to gh-pages (create orphan if it doesn't exist)
if git checkout gh-pages 2>/dev/null; then
echo "Switched to existing gh-pages branch"
else
# Create orphan branch and clear the index to avoid committing entire repo
git checkout --orphan gh-pages
git rm -rf . 2>/dev/null || true
git clean -fd 2>/dev/null || true
fi
# Create test-plans directory
mkdir -p test-plans
# Copy files from temp (overwrites if exists - handles re-runs)
cp /tmp/test-plan-${VERSION}.json test-plans/
cp /tmp/test-plan-${VERSION}.html test-plans/
# Add and commit
git add test-plans/
git commit -m "Add test plan for RC ${VERSION}" || echo "No changes to commit"
# Push to gh-pages
git push origin gh-pages
- name: Update build comment with test plan links
if: steps.generate.outputs.test_plan_generated == 'true'
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.check-release.outputs.version }}';
const buildNumber = '${{ steps.extract-build.outputs.build_number }}';
const commentId = context.payload.comment.id;
// Fetch latest comment body to avoid race conditions
const { data: comment } = await github.rest.issues.getComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId
});
const currentBody = comment.body;
const baseUrl = `https://metamask.github.io/metamask-mobile/test-plans`;
const htmlUrl = `${baseUrl}/test-plan-${version}.html`;
const jsonUrl = `${baseUrl}/test-plan-${version}.json`;
// Add test plan row to the existing comment
const testPlanSection = `
---
🤖 **AI Test Plan:** [View](${htmlUrl}) | [JSON](${jsonUrl})`;
// Update the comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: currentBody + testPlanSection
});
- name: Post failure notice
if: steps.check-release.outputs.is_release == 'true' && steps.generate.outputs.test_plan_generated != 'true'
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.check-release.outputs.version }}';
const commentId = context.payload.comment.id;
const runId = context.runId;
const logsUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
// Fetch latest comment body to avoid race conditions
const { data: comment } = await github.rest.issues.getComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId
});
const currentBody = comment.body;
const failureSection = `
---
⚠️ **AI Test Plan generation failed** - [View logs](${logsUrl})`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: currentBody + failureSection
});