Skip to content

Commit 3cb2fe3

Browse files
authored
Merge pull request Expensify#74508 from abzokhattab/feat-format-codecov-comment
2 parents 5b3143f + d757288 commit 3cb2fe3

6 files changed

Lines changed: 12990 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name: "Format CodeCov Comment"
2+
description: "Reformats CodeCov PR comments to display only relevant information"
3+
inputs:
4+
GITHUB_TOKEN:
5+
description: "Github token for authentication"
6+
required: true
7+
runs:
8+
using: "node20"
9+
main: "./index.js"
10+
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import CONST from '@github/libs/CONST';
4+
import GithubUtils from '@github/libs/GithubUtils';
5+
6+
/**
7+
* Extracts the Coverage Δ table from a CodeCov comment
8+
*/
9+
function extractCoverageDeltaTable(body: string): string | null {
10+
// Match the table that contains "Coverage Δ" - handle both markdown tables (with |) and plain text
11+
// The regex accounts for markdown link syntax like [Files with missing lines](url)
12+
const tableHeaderRegex = /[|\s]*\[?Files with missing lines\]?(?:\([^)]*\))?[|\s]*Coverage Δ[|\s]*/i;
13+
const tableMatch = body.match(tableHeaderRegex);
14+
15+
if (!tableMatch) {
16+
return null;
17+
}
18+
19+
const startIndex = tableMatch.index ?? 0;
20+
21+
// Find the table by looking for the header line and extracting everything until we hit the "New features" section or two consecutive newlines
22+
const remainingText = body.slice(startIndex);
23+
const lines = remainingText.split('\n');
24+
25+
const tableLines = [];
26+
let emptyLineCount = 0;
27+
let foundTableStart = false;
28+
29+
for (const line of lines) {
30+
const trimmedLine = line.trim();
31+
32+
// Skip lines until we find the actual table header (line starting with |)
33+
if (!foundTableStart) {
34+
if (trimmedLine.startsWith('|') && trimmedLine.includes('Coverage Δ')) {
35+
foundTableStart = true;
36+
} else {
37+
continue;
38+
}
39+
}
40+
41+
// Stop at the "New features" section (can be emoji or <details> tag)
42+
if (trimmedLine.includes('🚀 New features') || trimmedLine.includes(':rocket: New features') || trimmedLine.startsWith('<details>')) {
43+
break;
44+
}
45+
46+
// Track empty lines
47+
if (trimmedLine === '') {
48+
emptyLineCount++;
49+
// Stop if we hit 2 consecutive empty lines
50+
if (emptyLineCount >= 2) {
51+
break;
52+
}
53+
continue;
54+
} else {
55+
emptyLineCount = 0;
56+
}
57+
58+
tableLines.push(line);
59+
}
60+
61+
// Filter out any empty or whitespace-only lines to ensure proper table formatting
62+
const cleanedLines = tableLines.filter((line) => line.trim() !== '');
63+
const result = cleanedLines.join('\n').trim();
64+
65+
// Return null if no valid table content was found
66+
return result.length > 0 ? result : null;
67+
}
68+
69+
/**
70+
* Checks if the comment contains any downward arrows (decreased coverage)
71+
*/
72+
function hasDecreasedCoverage(body: string): boolean {
73+
// Check for both emoji and markdown syntax
74+
return body.includes('⬇️') || body.includes(':arrow_down:');
75+
}
76+
77+
/**
78+
* Extracts the header from a CodeCov comment (preserves the markdown link)
79+
*/
80+
function extractCodeCovHeader(body: string): string {
81+
// Extract the header line (## [Codecov](url) Report)
82+
const headerMatch = body.match(/^##\s*\[Codecov\]\([^)]*\)\s*Report/m);
83+
if (headerMatch) {
84+
return headerMatch[0];
85+
}
86+
// Fallback to plain header
87+
return '## Codecov Report';
88+
}
89+
90+
/**
91+
* Formats a CodeCov comment for the "all lines covered" case (no table)
92+
*/
93+
function formatAllLinesCoveredComment(originalBody: string): string {
94+
// Extract the original header
95+
const header = extractCodeCovHeader(originalBody);
96+
97+
// Extract everything between the header and the "New features" section
98+
const lines = originalBody.split('\n');
99+
const contentLines = [];
100+
let foundHeader = false;
101+
102+
for (const line of lines) {
103+
const trimmedLine = line.trim();
104+
105+
// Skip until we find content after the header
106+
if (!foundHeader) {
107+
if (trimmedLine.startsWith('##') && trimmedLine.includes('Codecov')) {
108+
foundHeader = true;
109+
}
110+
continue;
111+
}
112+
113+
// Stop at "New features" section
114+
if (trimmedLine.includes(':rocket:') || trimmedLine.includes('🚀') || trimmedLine.startsWith('<details>')) {
115+
break;
116+
}
117+
118+
// Skip empty lines at the start
119+
if (contentLines.length === 0 && trimmedLine === '') {
120+
continue;
121+
}
122+
123+
contentLines.push(line);
124+
}
125+
126+
// Clean up content and build the formatted comment
127+
const content = contentLines.join('\n').trim();
128+
return `${header}\n${content}`;
129+
}
130+
131+
/**
132+
* Formats a CodeCov comment according to specifications
133+
*/
134+
function formatCodeCovComment(originalBody: string): string | null {
135+
// Extract the original header to preserve the link
136+
const header = extractCodeCovHeader(originalBody);
137+
138+
// Check if this is the "all lines covered" case (no table)
139+
if (originalBody.includes('All modified and coverable lines are covered by tests')) {
140+
const hasCoverageTable = originalBody.includes('Coverage Δ');
141+
if (!hasCoverageTable) {
142+
// Format it by removing "New features" section but keeping the rest
143+
return formatAllLinesCoveredComment(originalBody);
144+
}
145+
}
146+
147+
// Extract the Coverage Δ table
148+
const coverageTable = extractCoverageDeltaTable(originalBody);
149+
150+
if (!coverageTable) {
151+
return null;
152+
}
153+
154+
// Determine the message based on decreased coverage
155+
let message: string;
156+
if (hasDecreasedCoverage(originalBody)) {
157+
message =
158+
"❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation [here](https://github.com/Expensify/App/blob/main/contributingGuides/CodeCov.md) for how to interpret this table.";
159+
} else {
160+
message = '✅ Changes either increased or maintained existing code coverage, great job!';
161+
}
162+
163+
// Build the new comment body with the original header
164+
const newBody = `${header}
165+
166+
${message}
167+
168+
${coverageTable}`;
169+
170+
return newBody;
171+
}
172+
173+
async function run() {
174+
try {
175+
// Check if this is a comment event
176+
if (context.eventName !== 'issue_comment') {
177+
console.log('This action only runs on issue_comment events');
178+
return;
179+
}
180+
181+
const commentId = context.payload.comment?.id;
182+
const commentBody = context.payload.comment?.body as string | undefined;
183+
const commentUser = context.payload.comment?.user as {login?: string} | undefined;
184+
const commentAuthor = commentUser?.login;
185+
186+
// Validate required fields
187+
if (!commentBody || !commentId || typeof commentBody !== 'string' || typeof commentId !== 'number') {
188+
console.log('Missing or invalid comment data');
189+
return;
190+
}
191+
192+
if (commentAuthor !== 'codecov[bot]') {
193+
console.log(`Comment is not from CodeCov (author: ${commentAuthor})`);
194+
return;
195+
}
196+
197+
// Check if the comment is a CodeCov report
198+
// CodeCov header format: ## [Codecov](url) Report or ## Codecov Report
199+
const isCodeCovReport =
200+
commentBody.includes('Codecov') &&
201+
commentBody.includes('Report') &&
202+
(commentBody.includes('Coverage Δ') || commentBody.includes('All modified and coverable lines are covered by tests'));
203+
204+
if (!isCodeCovReport) {
205+
console.log('Comment does not appear to be a CodeCov report');
206+
return;
207+
}
208+
209+
console.log('Found a CodeCov comment, formatting...');
210+
211+
// Format the comment
212+
const formattedBody = formatCodeCovComment(commentBody);
213+
214+
if (!formattedBody || formattedBody.trim() === '') {
215+
console.log('Comment should remain unchanged or formatting failed');
216+
return;
217+
}
218+
219+
// Safety check: Don't update if formatted body is identical to original
220+
if (formattedBody === commentBody) {
221+
console.log('Formatted body is identical to original, no update needed');
222+
return;
223+
}
224+
225+
// Update the comment
226+
await GithubUtils.octokit.issues.updateComment({
227+
owner: CONST.GITHUB_OWNER,
228+
repo: CONST.APP_REPO,
229+
// eslint-disable-next-line @typescript-eslint/naming-convention
230+
comment_id: commentId,
231+
body: formattedBody,
232+
});
233+
234+
console.log('Successfully formatted CodeCov comment! 🎉');
235+
} catch (error) {
236+
console.error('Error formatting CodeCov comment:', error);
237+
if (error instanceof Error) {
238+
core.setFailed(error.message);
239+
}
240+
}
241+
}
242+
243+
if (require.main === module) {
244+
run();
245+
}
246+
247+
export default run;

0 commit comments

Comments
 (0)