|
5 | 5 |
|
6 | 6 | import * as core from '@actions/core'; |
7 | 7 | import * as github from '@actions/github'; |
| 8 | +import * as fs from 'fs'; |
| 9 | +import * as path from 'path'; |
| 10 | + |
| 11 | +interface ValidationResult { |
| 12 | + errors: string[]; |
| 13 | + warnings: string[]; |
| 14 | + stats: {expected: number; found: number; checked: number; unchecked: number}; |
| 15 | +} |
| 16 | + |
| 17 | +const UNCHECKED_PATTERN = /- \[ \]/g; |
| 18 | +const CHECKED_PATTERN = /- \[x\]/gi; |
| 19 | + |
| 20 | +function getAuthorChecklistSection(body: string): string { |
| 21 | + const startMarker = '### Author Checklist'; |
| 22 | + const endMarker = '### Screenshots/Videos'; |
| 23 | + const startIndex = body.indexOf(startMarker); |
| 24 | + if (startIndex === -1) { |
| 25 | + return ''; |
| 26 | + } |
| 27 | + const afterStart = startIndex + startMarker.length; |
| 28 | + const endIndex = body.indexOf(endMarker, afterStart); |
| 29 | + return endIndex === -1 ? body.substring(afterStart) : body.substring(afterStart, endIndex); |
| 30 | +} |
| 31 | + |
| 32 | +function getExpectedChecklistCount(): number { |
| 33 | + const templatePath = path.resolve(process.env.GITHUB_WORKSPACE || '.', '.github/PULL_REQUEST_TEMPLATE.md'); |
| 34 | + const template = fs.readFileSync(templatePath, 'utf8'); |
| 35 | + const checklistSection = getAuthorChecklistSection(template); |
| 36 | + return (checklistSection.match(/- \[ \]/g) || []).length; |
| 37 | +} |
8 | 38 |
|
9 | 39 | function getSectionContent(body: string, sectionName: string): string { |
10 | 40 | const match = body.match(new RegExp(`### ${sectionName}\\s*\\n([\\s\\S]*?)(?=###|$)`)); |
11 | 41 | return (match?.[1] || '').replace(/<!-[\s\S]*?->/g, '').trim(); |
12 | 42 | } |
13 | 43 |
|
| 44 | +function validateChecklist(body: string): ValidationResult { |
| 45 | + const errors: string[] = []; |
| 46 | + const warnings: string[] = []; |
| 47 | + const checklistSection = getAuthorChecklistSection(body); |
| 48 | + |
| 49 | + if (!checklistSection.trim()) { |
| 50 | + return { |
| 51 | + errors: ['Author Checklist section not found. Please use the PR template.'], |
| 52 | + warnings: [], |
| 53 | + stats: {expected: 0, found: 0, checked: 0, unchecked: 0}, |
| 54 | + }; |
| 55 | + } |
| 56 | + |
| 57 | + const unchecked = (checklistSection.match(UNCHECKED_PATTERN) || []).length; |
| 58 | + const checked = (checklistSection.match(CHECKED_PATTERN) || []).length; |
| 59 | + const found = unchecked + checked; |
| 60 | + const expected = getExpectedChecklistCount(); |
| 61 | + |
| 62 | + if (found < expected) { |
| 63 | + errors.push(`Found ${found} checklist item(s) but expected at least ${expected}. It looks like items may have been removed. Please use the full PR template.`); |
| 64 | + } |
| 65 | + |
| 66 | + if (unchecked > 0) { |
| 67 | + errors.push( |
| 68 | + `${unchecked} checklist item(s) are unchecked. All items must be checked before merging — including items that don't apply (check them and note why if needed).`, |
| 69 | + ); |
| 70 | + } |
| 71 | + |
| 72 | + // Section warnings |
| 73 | + if (!getSectionContent(body, 'Automated Tests')) { |
| 74 | + warnings.push('The "Automated Tests" section is empty. Please describe the automated tests you added, or explain why automated tests are not needed for this change.'); |
| 75 | + } |
| 76 | + if (!getSectionContent(body, 'Manual Tests')) { |
| 77 | + warnings.push('The "Manual Tests" section is empty. Please describe how you manually tested this change.'); |
| 78 | + } |
| 79 | + const issues = getSectionContent(body, 'Related Issues'); |
| 80 | + if (issues === 'GH_LINK' || !issues) { |
| 81 | + warnings.push('The "Related Issues" section still contains the GH_LINK placeholder or is empty. Please replace it with the actual GitHub issue link.'); |
| 82 | + } |
| 83 | + |
| 84 | + return {errors, warnings, stats: {expected, found, checked, unchecked}}; |
| 85 | +} |
| 86 | + |
| 87 | +function buildSummary(result: ValidationResult): string { |
| 88 | + const parts: string[] = []; |
| 89 | + if (result.errors.length > 0) { |
| 90 | + parts.push('## PR Checklist Validation Failed\n'); |
| 91 | + parts.push('### Errors\n'); |
| 92 | + result.errors.forEach((e) => parts.push(`- ${e}`)); |
| 93 | + } |
| 94 | + if (result.warnings.length > 0) { |
| 95 | + parts.push('\n### Warnings\n'); |
| 96 | + result.warnings.forEach((w) => parts.push(`- ${w}`)); |
| 97 | + } |
| 98 | + parts.push('\n### Checklist Stats\n'); |
| 99 | + parts.push('| Metric | Value |'); |
| 100 | + parts.push('|--------|-------|'); |
| 101 | + parts.push(`| Expected items | ${result.stats.expected} |`); |
| 102 | + parts.push(`| Found items | ${result.stats.found} |`); |
| 103 | + parts.push(`| Checked | ${result.stats.checked} |`); |
| 104 | + parts.push(`| Unchecked | ${result.stats.unchecked} |`); |
| 105 | + return parts.join('\n'); |
| 106 | +} |
| 107 | + |
14 | 108 | async function run() { |
15 | 109 | try { |
16 | 110 | const body = github.context.payload.pull_request?.body || ''; |
17 | | - const errors: string[] = []; |
18 | | - |
19 | | - // Check that all checklist items are checked |
20 | | - const uncheckedPattern = /- \[ \]/g; |
21 | | - const checkedPattern = /- \[x\]/gi; |
22 | | - const uncheckedMatches = body.match(uncheckedPattern) || []; |
23 | | - const checkedMatches = body.match(checkedPattern) || []; |
24 | | - const totalCheckboxes = uncheckedMatches.length + checkedMatches.length; |
25 | | - |
26 | | - if (totalCheckboxes === 0) { |
27 | | - errors.push('No checklist items found in the PR description. Please use the PR template and complete the Author Checklist.'); |
28 | | - } else if (uncheckedMatches.length > 0) { |
29 | | - errors.push( |
30 | | - `${uncheckedMatches.length} checklist item(s) are unchecked. All checklist items must be checked before merging — including items that don't apply (check them and note why if needed).`, |
31 | | - ); |
32 | | - } |
33 | | - |
34 | | - // Warn if "Automated Tests" section is empty |
35 | | - const automatedTestsContent = getSectionContent(body, 'Automated Tests'); |
36 | | - if (automatedTestsContent.length === 0) { |
37 | | - core.warning('The "Automated Tests" section is empty. Please describe the automated tests you added, or explain why automated tests are not needed for this change.'); |
38 | | - } |
| 111 | + const result = validateChecklist(body); |
39 | 112 |
|
40 | | - // Warn if "Manual Tests" section is empty |
41 | | - const manualTestsContent = getSectionContent(body, 'Manual Tests'); |
42 | | - if (manualTestsContent.length === 0) { |
43 | | - core.warning('The "Manual Tests" section is empty. Please describe how you manually tested this change.'); |
44 | | - } |
| 113 | + result.warnings.forEach((w) => core.warning(w)); |
45 | 114 |
|
46 | | - // Warn if GH_LINK placeholder is still present |
47 | | - const relatedIssuesContent = getSectionContent(body, 'Related Issues'); |
48 | | - if (relatedIssuesContent === 'GH_LINK' || relatedIssuesContent.length === 0) { |
49 | | - core.warning('The "Related Issues" section still contains the GH_LINK placeholder or is empty. Please replace it with the actual GitHub issue link.'); |
| 115 | + if (result.errors.length > 0 || result.warnings.length > 0) { |
| 116 | + core.summary.addRaw(buildSummary(result)); |
| 117 | + await core.summary.write(); |
50 | 118 | } |
51 | 119 |
|
52 | | - // Fail if there are errors |
53 | | - if (errors.length > 0) { |
54 | | - const summary = `## PR Checklist Validation Failed\n\n${errors.map((error) => `- ${error}`).join('\n')}\n\nPlease complete the checklist and update the PR description.`; |
55 | | - |
56 | | - core.summary.addRaw(summary); |
57 | | - await core.summary.write(); |
58 | | - core.setFailed(errors.join('\n')); |
| 120 | + if (result.errors.length > 0) { |
| 121 | + core.setFailed(result.errors.join('\n')); |
59 | 122 | } |
60 | 123 | } catch (error) { |
61 | 124 | core.setFailed((error as Error).message); |
|
0 commit comments