Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@ All notable changes to CloudVoyager are documented in this file. Entries are ord

---

## Bug Fix: Issue Migration Data Loss for Large Projects (2026-04-23)
<!-- updated: 2026-04-23_01:50:00 -->
## Bug Fix: Issue Migration Data Loss + SCM Date-Bucket Distribution (2026-04-23)
<!-- updated: 2026-04-23_13:15:00 -->

Fixed two bugs causing significant data loss during migration of projects with >5K issues.
Fixed issue migration data loss and implemented SCM-based date-bucket distribution to work around SonarCloud's 10K Elasticsearch visualization cap.

**Bug 1 — Batch distributor silently drops issues (critical):** The batch distributor split large issue sets into 5K-batch analyses with backdated dates. However, SonarCloud's issue tracker treats each analysis as a complete snapshot — when batch N+1 is processed, all issues from batch N that don't appear in batch N+1 are closed. Result: only the last batch's issues survive. For Angular Framework (31K issues split into 7 batches), only ~2K issues remained on SonarCloud. **Fix:** Disabled batch distribution entirely. All issues are now uploaded in a single analysis. The 10K Elasticsearch visualization cap in the SonarCloud UI is a display limitation only — measures and underlying data are accurate regardless.
**Bug 1 — Batch distributor silently drops issues (critical):** The old batch distributor split large issue sets into separate 5K-batch analyses. SonarCloud's issue tracker treats each analysis as a complete snapshot — issues from prior analyses not in the current one are closed. Only the last batch's issues survived. **Fix:** Disabled multi-analysis batching. All issues now upload in a single analysis.

**Bug 2 — Missing IN_SANDBOX status in SQ 2025 pipeline:** The `issueStatuses` parameter in the SQ 2025 client's `getIssues` and `getIssuesWithComments` methods was missing the `IN_SANDBOX` status (new in SQ 2025) and incorrectly included `CLOSED` (not a valid `issueStatuses` value in SQ 2025). Updated both `issue-methods.js` and `issues-hotspots.js` in the sq-2025 pipeline.
**Bug 2 — Missing IN_SANDBOX status in SQ 2025 pipeline:** The `issueStatuses` parameter was missing `IN_SANDBOX` (new in SQ 2025) and incorrectly included `CLOSED`. Fixed in both `issue-methods.js` and `issues-hotspots.js`.

**Verification results (3 test projects):**
| Project | SQ violations | SC violations | Match |
|---------|-------------|-------------|-------|
| Sonar Solutions Easy Nodejs | 15 | 15 | exact |
| Angular Framework | 31,642 | 31,641 | 99.997% |
| My MuleSoft Project | 1,278 | 6,716* | pending metadata sync |
**Feature — SCM date-bucket distribution:** SonarCloud's UI caps the issues list at 10K per creation-date bucket. To work around this, issues are sorted by file and grouped into ≤5K-issue batches. Each batch's files have their SCM changeset blame dates set to a different date (30 days apart). The CE uses SCM blame dates to set creation dates for new issues, spreading them across multiple date buckets within a single analysis.

\* MuleSoft shows higher violations on SC because all 6,732 migrated issues are initially OPEN; the metadata sync transitions them to their correct statuses (FALSE_POSITIVE, ACCEPTED, FIXED, etc.), after which the count matches.
**Verification results (Angular Framework — 31,642 issues):**
| Date Bucket | Issues |
|-------------|--------|
| Oct 2025 | 4,854 |
| Nov 2025 | 4,816 |
| Dec 2025 | 4,959 |
| Jan 2026 | 4,767 |
| Feb 2026 | 4,921 |
| Mar 2026 | 4,958 |
| Apr 2026 | 2,366 |
| **Total** | **31,641** (SQ: 31,642) |

**Files changed:**
- `src/shared/utils/batch-distributor/helpers/should-batch.js` — disabled batching
- `src/shared/utils/batch-distributor/helpers/should-batch.js` — disabled multi-analysis batching
- `src/shared/utils/batch-distributor/helpers/backdate-changesets.js` — new: SCM date-bucket distribution
- `src/shared/utils/batch-distributor/helpers/compute-batch-date.js` — 30-day spacing between batches
- `src/pipelines/sq-2025/sonarqube/api-client/helpers/issue-methods.js` — fixed status list
- `src/pipelines/sq-2025/sonarqube/api/issues-hotspots.js` — fixed status constant
- All 6 pipeline `transfer-branch` entry points — call `backdateChangesets` before protobuf build

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export async function transferBranchBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap, wait = true } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
4 changes: 3 additions & 1 deletion src/pipelines/sq-10.0/transfer-branch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildAndEncodeReport } from './helpers/build-and-encode-report.js';
import { uploadReport } from './helpers/upload-report.js';
import { computeBranchStats } from './helpers/compute-branch-stats.js';
import { transferBranchBatched } from './helpers/transfer-branch-batched.js';
import { shouldBatch } from '../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../shared/utils/batch-distributor.js';

export async function transferBranch({ extractedData, sonarcloudConfig, sonarCloudProfiles, branchName, referenceBranchName, wait, sonarCloudClient, label, isMainBranch = false, sonarCloudRepos = new Set(), ruleEnrichmentMap = new Map() }) {
if (shouldBatch(extractedData)) {
Expand All @@ -17,6 +17,8 @@ export async function transferBranch({ extractedData, sonarcloudConfig, sonarClo
return { stats: computeBranchStats(extractedData), ceTask };
}

backdateChangesets(extractedData);

const encodedReport = buildAndEncodeReport({
extractedData, sonarcloudConfig, sonarCloudProfiles,
branchName, referenceBranchName, sonarCloudRepos, ruleEnrichmentMap, label
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export async function transferBranchBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ProtobufEncoder } from '../../protobuf/encoder.js';
import { ReportUploader } from '../../sonarcloud/uploader.js';
import { computeBranchStats } from './compute-branch-stats.js';
import { transferBranchBatched } from './transfer-branch-batched.js';
import { shouldBatch } from '../../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../../shared/utils/batch-distributor.js';

// -------- Transfer Single Branch --------

Expand All @@ -21,6 +21,8 @@ export async function transferBranch({ extractedData, sonarcloudConfig, sonarClo
return { stats: computeBranchStats(extractedData), ceTask };
}

backdateChangesets(extractedData);

logger.info(`[${label}] Building protobuf messages...`);
const builder = new ProtobufBuilder(extractedData, sonarcloudConfig, sonarCloudProfiles, {
sonarCloudBranchName: branchName, referenceBranchName, sonarCloudRepos, ruleEnrichmentMap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export async function transferBranchBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap, wait = true } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { buildAndEncodeReport } from './build-and-encode-report.js';
import { uploadReport } from './upload-report.js';
import { computeBranchStats } from './compute-branch-stats.js';
import { transferBranchBatched } from './transfer-branch-batched.js';
import { shouldBatch } from '../../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../../shared/utils/batch-distributor.js';

// -------- Main Logic --------

Expand All @@ -17,6 +17,8 @@ export async function transferBranch({ extractedData, sonarcloudConfig, sonarClo
return { stats: computeBranchStats(extractedData), ceTask };
}

backdateChangesets(extractedData);

const encodedReport = await buildAndEncodeReport({
extractedData, sonarcloudConfig, sonarCloudProfiles,
branchName, referenceBranchName, label, sonarCloudRepos, ruleEnrichmentMap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export async function transferBranchBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ProtobufEncoder } from '../../protobuf/encoder.js';
import { ReportUploader } from '../../sonarcloud/uploader.js';
import { computeBranchStats } from './compute-branch-stats.js';
import { transferBranchBatched } from './transfer-branch-batched.js';
import { shouldBatch } from '../../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../../shared/utils/batch-distributor.js';
import logger from '../../../../shared/utils/logger.js';

// -------- Main Logic --------
Expand All @@ -21,6 +21,8 @@ export async function transferBranch({ extractedData, sonarcloudConfig, sonarClo
return { stats: computeBranchStats(extractedData), ceTask };
}

backdateChangesets(extractedData);

logger.info(`[${label}] Building protobuf messages...`);
const builder = new ProtobufBuilder(extractedData, sonarcloudConfig, sonarCloudProfiles, {
sonarCloudBranchName: branchName, referenceBranchName, sonarCloudRepos, ruleEnrichmentMap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import logger from '../../../../shared/utils/logger.js';
/** Split issues into batches and upload each as a separate scanner report. */
export async function buildAndUploadBatched(opts) {
const { extractedData, label } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing backdateChangesets call in sq-2025 transfer-branch pipeline

High Severity

The sq-2025/transfer-branch/index.js entry point is the only one that was not updated to call backdateChangesets. All other pipeline entry points (sq-10.0, sq-10.4, sq-9.9, and even sq-2025's own transfer-pipeline variant) correctly call backdateChangesets(extractedData) in the non-batched path. This file still only imports shouldBatch (without backdateChangesets) and never performs SCM date-bucket distribution, so large projects migrated through the sq-2025 transfer-branch path will hit the 10K Elasticsearch visualization cap. The changelog's claim that "All 6 pipeline transfer-branch entry points" were updated is incorrect.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7a7116. Configure here.


const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export async function transferBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap } = opts;

// Sort issues by component key so cumulative batches add issues from NEW
// files each time. This prevents the CE's fuzzy issue tracker from matching
// new issues with existing ones on the same files.
extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { buildProtobufMessages, encodeMessages } from './helpers/build-and-encode.js';
import { uploadReport } from './helpers/upload-report.js';
import { transferBatched } from './helpers/transfer-batched.js';
import { shouldBatch } from '../../../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../../../shared/utils/batch-distributor.js';

// -------- Branch Transfer --------

Expand All @@ -23,6 +23,8 @@ export async function transferBranch(options) {
return { stats: computeBranchStats(extractedData), ceTask };
}

backdateChangesets(extractedData);

const messages = buildProtobufMessages(extractedData, sonarcloudConfig, sonarCloudProfiles, branchName, referenceBranchName, sonarCloudRepos, ruleEnrichmentMap, label);
const encodedReport = await encodeMessages(messages, label);
const ceTask = await uploadReport(encodedReport, sonarcloudConfig, sonarCloudClient, branchName, isMainBranch, wait, label);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export async function transferBranchBatched(opts) {
referenceBranchName, sonarCloudClient, label, isMainBranch,
sonarCloudRepos, ruleEnrichmentMap } = opts;

extractedData.issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

const plan = computeBatchPlan(extractedData.issues.length);
const baseDate = extractedData.metadata.extractedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { encodeReport } from './encode-report.js';
import { uploadReport } from './upload-report.js';
import { buildBranchResult } from './build-branch-result.js';
import { transferBranchBatched } from './transfer-branch-batched.js';
import { shouldBatch } from '../../../../shared/utils/batch-distributor.js';
import { shouldBatch, backdateChangesets } from '../../../../shared/utils/batch-distributor.js';

// -------- Single Branch Transfer (build, encode, upload) --------

Expand All @@ -17,6 +17,8 @@ export async function transferBranch({ extractedData, sonarcloudConfig, sonarClo
return buildBranchResult(extractedData, ceTask);
}

backdateChangesets(extractedData);

const messages = buildProtobufMessages(extractedData, sonarcloudConfig, sonarCloudProfiles, branchName, referenceBranchName, sonarCloudRepos, ruleEnrichmentMap, label);
const encodedReport = await encodeReport(messages, label);
const ceTask = await uploadReport(encodedReport, sonarcloudConfig, sonarCloudClient, branchName, isMainBranch, wait, label);
Expand Down
2 changes: 1 addition & 1 deletion src/shared/utils/batch-distributor.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// -------- Batch Distributor Re-export --------

export { shouldBatch, computeBatchPlan, computeBatchDate, createBatchExtractedData } from './batch-distributor/index.js';
export { shouldBatch, computeBatchPlan, computeBatchDate, createBatchExtractedData, backdateChangesets } from './batch-distributor/index.js';
117 changes: 117 additions & 0 deletions src/shared/utils/batch-distributor/helpers/backdate-changesets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import logger from '../../logger.js';
import { ISSUE_BATCH_SIZE } from './should-batch.js';
import { computeBatchDate } from './compute-batch-date.js';

// -------- Backdate Changesets --------

const STUB_AUTHOR = 'cloudvoyager-migration@sonarcloud.io';

/**
* Modify SCM changeset blame dates so the CE assigns different creation
* dates to different groups of issues. This spreads issues across
* multiple date buckets within a **single** analysis.
*
* Strategy: sort issues by file, group files into ≤5K-issue batches,
* then set ALL lines of each file to that batch's date. Setting every
* line ensures the CE has no "newer" line to fall back on.
*
* Mutates extractedData.issues (sort order) and
* extractedData.changesets (dates) in place.
*/
export function backdateChangesets(extractedData) {
const issues = extractedData.issues || [];
if (issues.length <= ISSUE_BATCH_SIZE) return;

const baseDateISO = extractedData.metadata?.extractedAt || new Date().toISOString();

// Sort issues by component so files are contiguous
issues.sort((a, b) => (a.component || '').localeCompare(b.component || ''));

// Group files into batches of ≤ISSUE_BATCH_SIZE issues.
// Each file goes entirely into one batch (no splitting a file across batches).
const fileBatches = buildFileBatches(issues);
const batchCount = fileBatches.length;

if (batchCount <= 1) return;

logger.info(
`Backdating SCM data: ${issues.length} issues → ${batchCount} date buckets of ≤${ISSUE_BATCH_SIZE}`
);

let modifiedFiles = 0;

for (let batchIdx = 0; batchIdx < batchCount; batchIdx++) {
if (batchIdx === batchCount - 1) break; // last batch keeps original date

const batchDateMs = new Date(
computeBatchDate(baseDateISO, batchIdx, batchCount)
).getTime();

const batchRevision = `cloudvoyager-batch-${String(batchIdx + 1).padStart(4, '0')}`;

for (const compKey of fileBatches[batchIdx].files) {
const cs = extractedData.changesets.get(compKey);
if (!cs) continue;

// Replace the single stub changeset with the backdated one
cs.changesets = [{
revision: batchRevision,
author: STUB_AUTHOR,
date: batchDateMs,
}];
// Point every line to changeset index 0 (the only entry)
cs.changesetIndexByLine.fill(0);

modifiedFiles++;
}
}

logger.info(
`Modified SCM data for ${modifiedFiles} files across ${batchCount} date buckets`
);
}

/**
* Walk the sorted issues array and collect files into batches.
* Each batch accumulates files until the issue count exceeds
* ISSUE_BATCH_SIZE, then a new batch starts.
*/
function buildFileBatches(issues) {
const batches = [{ files: new Set(), issueCount: 0 }];
let currentFile = null;
let currentFileIssueCount = 0;

for (const issue of issues) {
const compKey = issue.component;
if (!compKey) continue;

if (compKey !== currentFile) {
// New file — flush previous file's count and decide batch placement
if (currentFile && currentFileIssueCount > 0) {
let batch = batches[batches.length - 1];
if (batch.issueCount + currentFileIssueCount > ISSUE_BATCH_SIZE && batch.issueCount > 0) {
batches.push({ files: new Set(), issueCount: 0 });
batch = batches[batches.length - 1];
}
batch.files.add(currentFile);
batch.issueCount += currentFileIssueCount;
}
currentFile = compKey;
currentFileIssueCount = 0;
}
currentFileIssueCount++;
}

// Flush last file
if (currentFile && currentFileIssueCount > 0) {
let batch = batches[batches.length - 1];
if (batch.issueCount + currentFileIssueCount > ISSUE_BATCH_SIZE && batch.issueCount > 0) {
batches.push({ files: new Set(), issueCount: 0 });
batch = batches[batches.length - 1];
}
batch.files.add(currentFile);
batch.issueCount += currentFileIssueCount;
}

return batches;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// -------- Compute Batch Date --------

const DAYS_BETWEEN_BATCHES = 30;

/** Compute an ISO date string for a batch, going backwards from the base date. */
export function computeBatchDate(baseDateISO, batchIndex, totalBatches) {
if (totalBatches <= 1) return baseDateISO;

const baseDate = new Date(baseDateISO);
if (isNaN(baseDate.getTime())) throw new Error(`Invalid base date: ${baseDateISO}`);
const daysBack = totalBatches - 1 - batchIndex;
const daysBack = (totalBatches - 1 - batchIndex) * DAYS_BETWEEN_BATCHES;
const batchDate = new Date(baseDate);

batchDate.setDate(baseDate.getDate() - daysBack);
Expand Down
9 changes: 3 additions & 6 deletions src/shared/utils/batch-distributor/helpers/should-batch.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@

export const ISSUE_BATCH_SIZE = 5000;

// Batch distribution is disabled: each subsequent batch's analysis closes the
// previous batch's issues via SonarCloud's issue tracker (which treats each
// analysis as a complete snapshot). This means only the LAST batch's issues
// survive, silently dropping all earlier batches. Uploading all issues in a
// single analysis preserves the correct total even though the SonarCloud UI
// caps the issues list at 10K — the measures and underlying data are accurate.
// Multi-analysis batching is disabled — the CE's issue tracker closes
// issues from prior analyses. Instead, backdateChangesets() spreads
// creation dates within a single analysis via SCM blame data.
/** Returns true if the extracted data has more issues than the batch threshold. */
export function shouldBatch(_extractedData) {
return false;
Expand Down
1 change: 1 addition & 0 deletions src/shared/utils/batch-distributor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { shouldBatch } from './helpers/should-batch.js';
export { computeBatchPlan } from './helpers/compute-batch-plan.js';
export { computeBatchDate } from './helpers/compute-batch-date.js';
export { createBatchExtractedData } from './helpers/create-batch-extracted-data.js';
export { backdateChangesets } from './helpers/backdate-changesets.js';