-
Notifications
You must be signed in to change notification settings - Fork 377
fix(ci): PR diff coverage uses base branch SHAs and aggregate 80% gate (port to 5.7-main) #2616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,9 @@ | |
| # Configuration | ||
| COVERAGE_THRESHOLD=${DIFF_COVERAGE_THRESHOLD:-80} | ||
| BASE_BRANCH=${BASE_BRANCH:-origin/main} | ||
| # When set (e.g. GitHub PR), diff against these SHAs so we measure exactly the PR patch, not the merge ref | ||
| DIFF_BASE_SHA=${DIFF_BASE_SHA:-} | ||
| DIFF_HEAD_SHA=${DIFF_HEAD_SHA:-} | ||
| GENERATE_MARKDOWN=${GENERATE_MARKDOWN:-false} # Set to 'true' for CI/CD to generate markdown report | ||
| SKIP_COVERAGE_CHECK=${SKIP_COVERAGE_CHECK:-false} # Set to 'true' to bypass coverage check (still runs but doesn't fail) | ||
|
|
||
|
|
@@ -71,15 +74,27 @@ | |
| echo -e "${GREEN}✓ Coverage report generated${NC}\n" | ||
|
|
||
| # Step 2: Check diff coverage using manual method (reliable path matching) | ||
| echo -e "${YELLOW}[2/3] Checking diff coverage against $BASE_BRANCH...${NC}" | ||
| echo -e "${YELLOW}Threshold: ${COVERAGE_THRESHOLD}%${NC}\n" | ||
| if [ -n "$DIFF_BASE_SHA" ] && [ -n "$DIFF_HEAD_SHA" ]; then | ||
| DIFF_RANGE="${DIFF_BASE_SHA}...${DIFF_HEAD_SHA}" | ||
| echo -e "${YELLOW}[2/3] Checking diff coverage for PR range ${DIFF_BASE_SHA:0:7}...${DIFF_HEAD_SHA:0:7}${NC}" | ||
| else | ||
| DIFF_RANGE="${BASE_BRANCH}...HEAD" | ||
| echo -e "${YELLOW}[2/3] Checking diff coverage against ${BASE_BRANCH}...HEAD${NC}" | ||
| fi | ||
| echo -e "${YELLOW}Threshold: ${COVERAGE_THRESHOLD}% (aggregate on changed executable lines)${NC}\n" | ||
|
|
||
| # Get changed files (run from project root) | ||
| # Include committed changes, staged changes, and unstaged changes | ||
| cd "$REPO_ROOT" | ||
| COMMITTED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| STAGED_FILES=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| UNSTAGED_FILES=$(git diff --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| if [ -n "$DIFF_BASE_SHA" ] && [ -n "$DIFF_HEAD_SHA" ]; then | ||
| COMMITTED_FILES=$(git diff --name-only "$DIFF_RANGE" 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| STAGED_FILES="" | ||
| UNSTAGED_FILES="" | ||
| else | ||
| # Local / workflow_dispatch: committed vs base, plus working tree | ||
| COMMITTED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| STAGED_FILES=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| UNSTAGED_FILES=$(git diff --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) | ||
| fi | ||
| # Combine all, remove duplicates, and filter to OneSignalSDK files | ||
| CHANGED_FILES=$(echo -e "$COMMITTED_FILES\n$STAGED_FILES\n$UNSTAGED_FILES" | grep -E '^OneSignalSDK/' | sort -u || true) | ||
|
|
||
|
|
@@ -102,6 +117,7 @@ | |
| export MARKDOWN_REPORT | ||
| export BASE_BRANCH | ||
| export REPO_ROOT | ||
| export DIFF_RANGE | ||
| python3 << PYEOF | ||
| import xml.etree.ElementTree as ET | ||
| import re | ||
|
|
@@ -116,47 +132,50 @@ | |
| markdown_report = os.environ.get('MARKDOWN_REPORT', 'diff_coverage.md') | ||
| base_branch = os.environ.get('BASE_BRANCH', 'origin/main') | ||
| repo_root_env = os.environ.get('REPO_ROOT') | ||
| diff_range = os.environ.get('DIFF_RANGE', '').strip() | ||
|
|
||
| def get_changed_lines(file_path, project_root): | ||
| """Get line numbers of added/modified lines from git diff""" | ||
| try: | ||
| # First try to get diff from committed changes | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--unified=0', base_branch + '...HEAD', '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
|
|
||
| # If no committed changes, check staged changes | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| if diff_range: | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--cached', '--unified=0', '--', file_path], | ||
| ['git', 'diff', '--unified=0', diff_range, '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
|
|
||
| # If no staged changes, check unstaged changes | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--unified=0', '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
|
|
||
| # If still nothing, try alternative base branch format | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| return None | ||
| else: | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--unified=0', base_branch, 'HEAD', '--', file_path], | ||
| ['git', 'diff', '--unified=0', base_branch + '...HEAD', '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
|
|
||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| return None | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--cached', '--unified=0', '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--unified=0', '--', file_path], | ||
|
Check failure on line 165 in OneSignalSDK/coverage/checkCoverage.sh
|
||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| result = subprocess.run( | ||
| ['git', 'diff', '--unified=0', base_branch, 'HEAD', '--', file_path], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=project_root | ||
| ) | ||
| if result.returncode != 0 or not result.stdout.strip(): | ||
| return None | ||
|
|
||
| changed_lines = set() | ||
| current_new_line = None | ||
|
|
@@ -217,11 +236,15 @@ | |
| total_lines = 0 | ||
| files_below_threshold = [] | ||
| files_checked = [] | ||
| eligible_paths = [] | ||
| markdown_output = [] | ||
|
|
||
| if generate_markdown: | ||
| markdown_output.append("## Diff Coverage Report (Changed Lines Only)\n") | ||
| markdown_output.append(f"**Threshold:** {threshold}%\n\n") | ||
| markdown_output.append( | ||
| f"**Gate:** aggregate coverage on **changed executable lines** must be ≥ **{threshold}%** " | ||
| "(JaCoCo line data for lines touched in the diff).\n\n" | ||
| ) | ||
| markdown_output.append("### Changed Files Coverage\n\n") | ||
|
|
||
| for changed_file in changed_files: | ||
|
|
@@ -237,7 +260,9 @@ | |
| match = re.search(r'src/main/(java|kotlin)/(.+)/([^/]+\.(kt|java))$', path_part) | ||
| if not match: | ||
| continue | ||
|
|
||
|
|
||
| eligible_paths.append(changed_file) | ||
|
|
||
| package_path = match.group(2) | ||
| filename = match.group(3) | ||
| package_name = package_path.replace('/', '/') | ||
|
|
@@ -273,18 +298,14 @@ | |
| coverage_pct = (file_covered / file_total * 100) if file_total > 0 else 100 | ||
|
|
||
| if generate_markdown: | ||
| status = "✅" if coverage_pct >= threshold else "❌" | ||
| changed_info = f" ({len(changed_lines)} changed lines)" if changed_lines else " (all lines - could not determine changed lines)" | ||
| markdown_output.append(f"- {status} **{filename}**: {file_covered}/{file_total} changed lines ({coverage_pct:.1f}%){changed_info}") | ||
| changed_info = f" ({len(changed_lines)} touched lines in diff)" if changed_lines else " (all lines — could not resolve diff)" | ||
| markdown_output.append(f"- **{filename}**: {file_covered}/{file_total} touched executable lines ({coverage_pct:.1f}%){changed_info}") | ||
| if coverage_pct < threshold: | ||
| files_below_threshold.append((filename, coverage_pct, file_uncovered)) | ||
| markdown_output.append(f" - ⚠️ Below threshold: {file_uncovered} uncovered changed lines") | ||
| markdown_output.append(f" - {file_uncovered} uncovered touched lines in this file") | ||
| else: | ||
| status = "✓" if coverage_pct >= threshold else "✗" | ||
| color = "" if coverage_pct >= threshold else "\033[0;31m" | ||
| reset = "\033[0m" if color else "" | ||
| changed_info = f" ({len(changed_lines)} changed lines)" if changed_lines else " (all lines - could not determine changed lines)" | ||
| print(f" {color}{status}{reset} {filename}: {file_covered}/{file_total} changed lines ({coverage_pct:.1f}%){changed_info}") | ||
| changed_info = f" ({len(changed_lines)} touched lines)" if changed_lines else " (all lines — could not resolve diff)" | ||
| print(f" {filename}: {file_covered}/{file_total} touched executable lines ({coverage_pct:.1f}%){changed_info}") | ||
| if coverage_pct < threshold: | ||
| files_below_threshold.append((filename, coverage_pct, file_uncovered)) | ||
| break | ||
|
|
@@ -299,39 +320,43 @@ | |
|
|
||
| if total_lines > 0: | ||
| overall_coverage = ((total_lines - total_uncovered) / total_lines * 100) | ||
|
|
||
| overall_pass = overall_coverage >= threshold | ||
|
|
||
| if generate_markdown: | ||
| markdown_output.append(f"\n### Overall Coverage (Changed Lines Only)\n") | ||
| markdown_output.append(f"**{total_lines - total_uncovered}/{total_lines}** changed lines covered ({overall_coverage:.1f}%)\n") | ||
|
|
||
| markdown_output.append(f"\n### Overall (aggregate gate)\n") | ||
| markdown_output.append( | ||
| f"**{total_lines - total_uncovered}/{total_lines}** touched executable lines covered " | ||
| f"({overall_coverage:.1f}% — requires ≥ {threshold}%)\n" | ||
| ) | ||
| if files_below_threshold: | ||
| markdown_output.append(f"\n### ❌ Coverage Check Failed\n") | ||
| markdown_output.append(f"Files below {threshold}% threshold:\n") | ||
| markdown_output.append(f"\n**Per-file detail** (informational; gate is aggregate above):\n") | ||
| for filename, pct, uncovered in files_below_threshold: | ||
| markdown_output.append(f"- **{filename}**: {pct:.1f}% ({uncovered} uncovered changed lines)\n") | ||
|
|
||
| markdown_output.append(f"- **{filename}**: {pct:.1f}% ({uncovered} uncovered touched lines)\n") | ||
|
|
||
| if not overall_pass: | ||
| markdown_output.append(f"\n### ❌ Coverage Check Failed\n") | ||
| markdown_output.append( | ||
| f"Aggregate coverage on touched lines is **{overall_coverage:.1f}%** (minimum **{threshold}%**).\n" | ||
| ) | ||
|
|
||
| # Write markdown file | ||
| with open(markdown_report, 'w') as f: | ||
| f.write('\n'.join(markdown_output)) | ||
|
|
||
| # Print to console | ||
| print('\n'.join(markdown_output)) | ||
|
|
||
| if files_below_threshold: | ||
| sys.exit(1) | ||
| else: | ||
| sys.exit(0) | ||
|
|
||
| sys.exit(0 if overall_pass else 1) | ||
| else: | ||
| print(f"\n Overall: {(total_lines - total_uncovered)}/{total_lines} changed lines covered ({overall_coverage:.1f}%)") | ||
|
|
||
| print(f"\n Overall: {(total_lines - total_uncovered)}/{total_lines} touched executable lines covered ({overall_coverage:.1f}%)") | ||
| print(f" Gate: ≥ {threshold}% aggregate on touched lines — {'PASS' if overall_pass else 'FAIL'}") | ||
|
|
||
| if files_below_threshold: | ||
| print(f"\n Files below {threshold}% threshold:") | ||
| print(f"\n Per-file (informational):") | ||
| for filename, pct, uncovered in files_below_threshold: | ||
| print(f" • {filename}: {pct:.1f}% ({uncovered} uncovered changed lines)") | ||
| sys.exit(1) | ||
| else: | ||
| print(f"\n ✓ All files meet {threshold}% threshold for changed lines") | ||
| sys.exit(0) | ||
| print(f" • {filename}: {pct:.1f}% ({uncovered} uncovered)") | ||
|
|
||
| sys.exit(0 if overall_pass else 1) | ||
| elif files_checked: | ||
| # Files were found but had no executable lines | ||
| if generate_markdown: | ||
|
|
@@ -340,28 +365,40 @@ | |
| with open(markdown_report, 'w') as f: | ||
| f.write('\n'.join(markdown_output)) | ||
| else: | ||
| print("\n ✓ All checked files have no executable lines (or fully covered)") | ||
| sys.exit(0) | ||
| else: | ||
| if eligible_paths: | ||
| msg = ( | ||
| "Changed Kotlin/Java sources under `src/main` could not be matched to the merged JaCoCo report " | ||
| "(or the diff has no executable touched lines). Check module wiring and that unit tests run for those sources." | ||
| ) | ||
| if generate_markdown: | ||
| markdown_output.append(f"\n### ❌ No usable coverage for changed sources\n\n{msg}\n") | ||
| with open(markdown_report, 'w') as f: | ||
| f.write('\n'.join(markdown_output)) | ||
| print('\n'.join(markdown_output)) | ||
| else: | ||
| print(f"\n ✗ {msg}") | ||
| sys.exit(1) | ||
| if generate_markdown: | ||
| markdown_output.append(f"\n### ⚠️ No Coverage Data\n") | ||
| markdown_output.append("No coverage data found for changed files\n") | ||
| markdown_output.append(f"\n### ✅ No main-source changes to gate\n") | ||
| markdown_output.append("No `src/main` Kotlin/Java changes in the diff.\n") | ||
| with open(markdown_report, 'w') as f: | ||
| f.write('\n'.join(markdown_output)) | ||
| else: | ||
| print("\n ⚠ No coverage data found for changed files") | ||
| print(" This may mean files aren't being compiled or tested") | ||
| print("\n ✓ No main-source Kotlin/Java changes in the diff") | ||
| sys.exit(0) | ||
| PYEOF | ||
|
|
||
| CHECK_RESULT=$? | ||
| if [ $CHECK_RESULT -eq 1 ]; then | ||
|
Check failure on line 395 in OneSignalSDK/coverage/checkCoverage.sh
|
||
|
Comment on lines
368
to
395
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The new Extended reasoning...What the bug is The PR introduces a new failure mode: when src/main Kotlin/Java files are changed but none appear in the JaCoCo report, the script exits 1 with a clear error about module wiring. This is implemented via two lists: The specific code path that triggers it In the scenario where FileA is found in JaCoCo with Why existing code doesn't prevent it The What the impact would be A module wiring issue — a src/main Kotlin source compiled into a module not included in the JaCoCo merge — will go undetected in CI. The build exits 0, the PR appears to pass coverage, and the structural problem (missing module from test run) is only visible as a per-file ' Addressing the refutation The refutation argues that 'none map to the merged JaCoCo report' is intentional — the feature is for the all-or-nothing case, and partial mismatches still show a per-file warning. However: (1) the per-file warning does not cause a non-zero exit code, so CI does not fail; (2) the PR description does not state this all-or-nothing limitation — it says 'Failing with a clear message when src/main Kotlin/Java files change in the diff but none map to the merged JaCoCo report', which could reasonably apply to the case where only some are missing; (3) the design goal (catching module wiring failures) is not served by a warning that exits 0. The refutation's logic would justify removing the Step-by-step proof
How to fix it In the |
||
| if [ "$GENERATE_MARKDOWN" != "true" ]; then | ||
| if [ -n "$BYPASS_REASON" ]; then | ||
| echo -e "\n${YELLOW}⚠ Coverage below threshold (files below ${COVERAGE_THRESHOLD}%)${NC}" | ||
| echo -e "\n${YELLOW}⚠ Aggregate touched-line coverage below ${COVERAGE_THRESHOLD}%${NC}" | ||
| echo -e "${YELLOW} Build will not fail due to bypass: $BYPASS_REASON${NC}\n" | ||
| else | ||
| echo -e "\n${RED}✗ Coverage check failed (files below ${COVERAGE_THRESHOLD}% threshold)${NC}\n" | ||
| echo -e "\n${RED}✗ Coverage check failed (aggregate on touched lines < ${COVERAGE_THRESHOLD}%)${NC}\n" | ||
| fi | ||
| else | ||
| # In markdown mode, update the report to indicate bypass if applicable | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The
elsebranch inget_changed_lines()(lines 149–178) that handles staged/unstaged fallbacks is dead code and can never execute, causing a regression in the local developer workflow. Because the shell always exportsDIFF_RANGEas a non-empty string (either the SHA-pair or${BASE_BRANCH}...HEAD), Python'sif diff_range:is always true — so a developer with staged-but-not-committed Kotlin/Java files will have those files inCHANGED_FILESbutget_changed_lines()will callgit diff --unified=0 origin/main...HEAD -- <staged_file>, return nothing, and fall back to checking all executable lines instead of only the changed ones, producing false failures or false passes.Extended reasoning...
The bug: unreachable else branch
In
get_changed_lines(), the new code usesif diff_range: … else: …where theelsebranch provides the staged/unstaged fallback chain that existed pre-PR. The problem is thatdiff_rangeis always non-empty: the shell always assignsDIFF_RANGEbefore exporting it — either${DIFF_BASE_SHA}...${DIFF_HEAD_SHA}(lines 77–78, when both SHAs are set) or${BASE_BRANCH}...HEAD(lines 80–81, the local/workflow_dispatch path, whereBASE_BRANCHdefaults toorigin/main). There is no code path that leavesDIFF_RANGEempty beforeexport DIFF_RANGE.Why the guard doesn't help
Python reads it as
diff_range = os.environ.get('DIFF_RANGE', '').strip()(around line 135). Since the environment variable is always a non-empty string likeorigin/main...HEAD,diff_rangeis always truthy. Theelseblock — which triesgit diff --cached(staged) and thengit diff(unstaged) as progressive fallbacks — can never execute.Concrete impact for local use (documented in the script header)
Step through the local case where a developer has staged-but-not-committed Kotlin/Java changes:
DIFF_RANGE="origin/main...HEAD";STAGED_FILESis populated viagit diff --cached --name-only;CHANGED_FILEStherefore includes the staged files.DIFF_RANGE="origin/main...HEAD"— truthy — so it always takes theif diff_range:path and callsgit diff --unified=0 origin/main...HEAD -- <staged_file>.Noneforchanged_lines.Pre-PR, the code tried
git diff --cachednext, which would correctly return the staged diff and pinpoint exactly the changed lines.Fix
Either (a) leave
DIFF_RANGEunset/empty in the non-SHA shell path and continue to rely on the Python fallback chain, or (b) detect the local case in Python asdiff_range == base_branch + '...HEAD'and add the staged/unstaged fallback after the primarygit diffcall returns empty, or (c) do the staged/unstaged fallbacks inside theif diff_range:block when the primary diff returns nothing.