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
33 changes: 33 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ All notable changes to CloudVoyager are documented in this file. Entries are ord

---

## Fix: Project Settings Migration (Issue #95) (2026-04-29)
<!-- updated: 2026-04-29_22:50:00 -->

Fixed project configuration settings (e.g., `sonar.exclusions`, `sonar.coverage.exclusions`) not being migrated from SonarQube Server to SonarQube Cloud.

**Root causes identified and fixed:**

1. **POST body format + Content-Type** — Settings were sent as URL query parameters (`/api/settings/set?key=...&values=...`) with a `null` body and `Content-Type: application/json` (from axios defaults). Changed to send parameters as a form-encoded POST body string with an explicit `Content-Type: application/x-www-form-urlencoded` header, which is the correct format for SonarQube/SonarCloud web API POST endpoints. The explicit header is required because the axios client's default `Content-Type: application/json` would not be overridden by auto-detection (axios uses `rewrite=false` for URLSearchParams).

2. **Truthy check bug in `dispatchSettingToApi`** — Used `if (setting.value)` which is a JavaScript truthy check. This silently skipped settings with empty string values (`value: ""`). Changed to `if (setting.value !== undefined && setting.value !== null)` for correctness.

3. **Improved logging** — Added per-setting logging showing which settings are being migrated, with a summary of applied/failed/skipped counts. Previously, failures were logged at `warn` level with no summary, making it hard to diagnose migration issues.

4. **Verification checker fix** — The settings comparison in `verify-settings.js` had the same truthy fallback issue (`sqSetting.value || sqSetting.values`), which could produce incorrect comparisons for settings with empty string values.

**Scope:** All 4 pipeline versions (sq-9.9, sq-10.0, sq-10.4, sq-2025) updated consistently.

**Files changed:**
- `src/shared/utils/settings-params.js` — Fixed truthy check, added skip logging
- `src/pipelines/sq-{9.9,10.0,10.4,2025}/sonarcloud/api/project-config/helpers/set-project-setting.js` (and equivalents) — POST body format fix
- `src/pipelines/sq-{9.9,10.0,10.4,2025}/sonarcloud/migrators/project-config/helpers/migrate-project-settings.js` — Improved logging
- `src/shared/verification/checkers/project-config/helpers/verify-settings.js` — Fixed value comparison
- `test/utils/settings-params.test.js` — New: 14 unit tests for `buildSettingsParams` and `dispatchSettingToApi`
- `test/sonarcloud/migrators/migrators.test.js` — Added empty string value dispatch test

**Regression test (Angular Framework, SQ 2026.2 → SC-staging):**
- Created 3 project-level settings on SQ: `sonar.exclusions` (3 values), `sonar.coverage.exclusions` (2 values), `sonar.cpd.exclusions` (1 value)
- Ran `migrate --only project-settings`: 3 applied, 0 failed, 0 skipped
- Verified all 3 settings match exactly between SQ and SC via API
- No settings-related warnings in logs

---

## Parallel Issue Sync with Worker Threads (2026-04-28)
<!-- updated: 2026-04-28_11:30:00 -->

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ import { buildSettingsParams } from '../../../../../../shared/utils/settings-par
export async function setProjectSetting(client, key, { value, values, fieldValues } = {}, component) {
logger.debug(`Setting ${key} on project ${component}`);
const params = buildSettingsParams({ key, component, value, values, fieldValues });
await client.post(`/api/settings/set?${params.toString()}`, null);
await client.post('/api/settings/set', params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { dispatchSettingToApi } from '../../../../../../shared/utils/settings-pa
// -------- Migrate Project Settings --------

export async function migrateProjectSettings(projectKey, settings, client) {
logger.info(`Migrating ${settings.length} project settings for ${projectKey}`);
if (!settings.length) { logger.info(`No project settings to migrate for ${projectKey}`); return; }
logger.info(`Migrating ${settings.length} project setting(s) for ${projectKey}: ${settings.map(s => s.key).join(', ')}`);

let ok = 0; let fail = 0; let skip = 0;
for (const setting of settings) {
try {
await dispatchSettingToApi(client, setting, projectKey);
const dispatched = await dispatchSettingToApi(client, setting, projectKey);
if (dispatched) ok++; else skip++;
} catch (error) {
fail++;
logger.warn(`Failed to set setting ${setting.key} on ${projectKey}: ${error.message}`);
}
}
logger.info(`Project settings for ${projectKey}: ${ok} applied, ${fail} failed, ${skip} skipped`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { buildSettingsParams } from '../../../../../../shared/utils/settings-par
export async function setProjectSetting(client, key, { value, values, fieldValues } = {}, component) {
logger.debug(`Setting ${key} on project ${component}`);
const params = buildSettingsParams({ key, component, value, values, fieldValues });
await client.post(`/api/settings/set?${params.toString()}`, null);
await client.post('/api/settings/set', params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}

export async function setProjectTags(client, projectKey, tags) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import { dispatchSettingToApi } from '../../../../../../shared/utils/settings-pa

// Migrate project settings (non-inherited configuration values).
export async function migrateProjectSettings(projectKey, settings, client) {
logger.info(`Migrating ${settings.length} project settings for ${projectKey}`);
if (!settings.length) { logger.info(`No project settings to migrate for ${projectKey}`); return; }
logger.info(`Migrating ${settings.length} project setting(s) for ${projectKey}: ${settings.map(s => s.key).join(', ')}`);

let ok = 0; let fail = 0; let skip = 0;
for (const setting of settings) {
try {
await dispatchSettingToApi(client, setting, projectKey);
const dispatched = await dispatchSettingToApi(client, setting, projectKey);
if (dispatched) ok++; else skip++;
} catch (error) {
fail++;
logger.warn(`Failed to set setting ${setting.key} on ${projectKey}: ${error.message}`);
}
}
logger.info(`Project settings for ${projectKey}: ${ok} applied, ${fail} failed, ${skip} skipped`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ import { buildSettingsParams } from '../../../../../../shared/utils/settings-par
export async function setProjectSetting(client, key, { value, values, fieldValues } = {}, component) {
logger.debug(`Setting ${key} on project ${component}`);
const params = buildSettingsParams({ key, component, value, values, fieldValues });
await client.post(`/api/settings/set?${params.toString()}`, null);
await client.post('/api/settings/set', params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { dispatchSettingToApi } from '../../../../../../shared/utils/settings-pa

/** Migrate non-inherited project settings to SonarCloud. */
export async function migrateProjectSettings(projectKey, settings, client) {
logger.info(`Migrating ${settings.length} project settings for ${projectKey}`);
if (!settings.length) { logger.info(`No project settings to migrate for ${projectKey}`); return; }
logger.info(`Migrating ${settings.length} project setting(s) for ${projectKey}: ${settings.map(s => s.key).join(', ')}`);

let ok = 0; let fail = 0; let skip = 0;
for (const setting of settings) {
try {
await dispatchSettingToApi(client, setting, projectKey);
const dispatched = await dispatchSettingToApi(client, setting, projectKey);
if (dispatched) ok++; else skip++;
} catch (error) {
fail++;
logger.warn(`Failed to set setting ${setting.key} on ${projectKey}: ${error.message}`);
}
}
logger.info(`Project settings for ${projectKey}: ${ok} applied, ${fail} failed, ${skip} skipped`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { buildSettingsParams } from '../../../../../../shared/utils/settings-par
export async function setProjectSetting(client, key, { value, values, fieldValues } = {}, component) {
logger.debug(`Setting ${key} on project ${component}`);
const params = buildSettingsParams({ key, component, value, values, fieldValues });
await client.post(`/api/settings/set?${params.toString()}`, null);
await client.post('/api/settings/set', params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}

export async function setProjectTags(client, projectKey, tags) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { dispatchSettingToApi } from '../../../../../../shared/utils/settings-pa
// -------- Migrate Project Settings --------

export async function migrateProjectSettings(projectKey, settings, client) {
logger.info(`Migrating ${settings.length} project settings for ${projectKey}`);
if (!settings.length) { logger.info(`No project settings to migrate for ${projectKey}`); return; }
logger.info(`Migrating ${settings.length} project setting(s) for ${projectKey}: ${settings.map(s => s.key).join(', ')}`);

let ok = 0; let fail = 0; let skip = 0;
for (const setting of settings) {
try {
await dispatchSettingToApi(client, setting, projectKey);
const dispatched = await dispatchSettingToApi(client, setting, projectKey);
if (dispatched) ok++; else skip++;
} catch (error) {
fail++;
logger.warn(`Failed to set setting ${setting.key} on ${projectKey}: ${error.message}`);
}
}
logger.info(`Project settings for ${projectKey}: ${ok} applied, ${fail} failed, ${skip} skipped`);
}
3 changes: 2 additions & 1 deletion src/shared/utils/settings-params.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ export function buildSettingsParams({ key, component, value, values, fieldValues
* Returns true if a value was dispatched, false if the setting was empty/skipped.
*/
export async function dispatchSettingToApi(client, setting, projectKey) {
if (setting.value) {
if (setting.value !== undefined && setting.value !== null) {
await client.setProjectSetting(setting.key, { value: setting.value }, projectKey);
} else if (setting.values?.length) {
await client.setProjectSetting(setting.key, { values: setting.values }, projectKey);
} else if (setting.fieldValues?.length) {
await client.setProjectSetting(setting.key, { fieldValues: setting.fieldValues }, projectKey);
} else {
logger.debug(`Skipping setting ${setting.key} — no value, values, or fieldValues`);
return false;
}
logger.debug(`Set setting ${setting.key} on ${projectKey}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export async function verifyProjectSettings(sqClient, scClient, sqProjectKey, sc
const sqNonInherited = sqSettings.filter(s => !s.inherited);
const scMap = new Map(scSettings.map(s => [s.key, s]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Logic duplication: pick reimplements the value-selection logic from dispatchSettingToApi in src/shared/utils/settings-params.js, but with a silent divergence: it omits fieldValues. dispatchSettingToApi handles all three shapes (value → values → fieldValues); pick only handles two.

Consequence: a setting that exists as fieldValues on both sides will have pick() return undefined for both, produce '""' on both sides, and always be reported as matching — even if the SC values are wrong. The only failure mode caught is if the setting is absent from SC entirely.

Extract a shared extractSettingValue(s) helper from settings-params.js and use it here:

// in settings-params.js (exported)
export function extractSettingValue(s) {
  if (s.value !== undefined && s.value !== null) return s.value;
  if (s.values?.length) return s.values;
  if (s.fieldValues?.length) return s.fieldValues;
  return undefined;
}

Then verify-settings.js simply calls extractSettingValue(s) instead of the inline pick.

  • Mark as noise

const pick = (s) => s.value !== undefined && s.value !== null ? s.value : (s.values?.length ? s.values : undefined);

for (const sqSetting of sqNonInherited) {
const scSetting = scMap.get(sqSetting.key);
if (!scSetting) { result.sqOnly.push({ key: sqSetting.key, value: sqSetting.value || sqSetting.values }); continue; }
const sqVal = JSON.stringify(sqSetting.value || sqSetting.values || '');
const scVal = JSON.stringify(scSetting.value || scSetting.values || '');
if (!scSetting) { result.sqOnly.push({ key: sqSetting.key, value: pick(sqSetting) }); continue; }
const sqVal = JSON.stringify(pick(sqSetting) ?? '');
const scVal = JSON.stringify(pick(scSetting) ?? '');
if (sqVal !== scVal) {
result.mismatches.push({ key: sqSetting.key, sqValue: sqSetting.value || sqSetting.values, scValue: scSetting.value || scSetting.values });
result.mismatches.push({ key: sqSetting.key, sqValue: pick(sqSetting), scValue: pick(scSetting) });
}
}

Expand Down
12 changes: 12 additions & 0 deletions test/sonarcloud/migrators/migrators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,18 @@ test('migrateProjectSettings handles empty settings', async t => {
t.is(client.setProjectSetting.callCount, 0);
});

test('migrateProjectSettings dispatches empty string value', async t => {
const client = mockClient();
const settings = [
{ key: 'sonar.test.inclusions', value: '' }
];

await migrateProjectSettings('proj', settings, client);

t.is(client.setProjectSetting.callCount, 1);
t.deepEqual(client.setProjectSetting.firstCall.args, ['sonar.test.inclusions', { value: '' }, 'proj']);
});

// ============================================================================
// project-config.js - migrateProjectTags
// ============================================================================
Expand Down
111 changes: 111 additions & 0 deletions test/utils/settings-params.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import test from 'ava';
import sinon from 'sinon';
import { buildSettingsParams, dispatchSettingToApi } from '../../src/shared/utils/settings-params.js';

test.afterEach(() => sinon.restore());

// ============================================================================
// buildSettingsParams
// ============================================================================

test('buildSettingsParams builds scalar value params', t => {
const params = buildSettingsParams({ key: 'sonar.cpd.enabled', component: 'proj', value: 'true' });
t.is(params.get('key'), 'sonar.cpd.enabled');
t.is(params.get('component'), 'proj');
t.is(params.get('value'), 'true');
t.deepEqual(params.getAll('values'), []);
t.deepEqual(params.getAll('fieldValues'), []);
});

test('buildSettingsParams builds multi-value params with repeated values', t => {
const params = buildSettingsParams({ key: 'sonar.exclusions', component: 'proj', values: ['**/*.test.js', '**/*.spec.js'] });
t.is(params.get('key'), 'sonar.exclusions');
t.is(params.get('component'), 'proj');
t.is(params.get('value'), null);
t.deepEqual(params.getAll('values'), ['**/*.test.js', '**/*.spec.js']);
});

test('buildSettingsParams builds fieldValues params as JSON', t => {
const fvs = [{ resourceKey: '**/*.js', ruleKey: 'squid:S001' }];
const params = buildSettingsParams({ key: 'sonar.issue.enforce.multicriteria', component: 'proj', fieldValues: fvs });
t.is(params.get('key'), 'sonar.issue.enforce.multicriteria');
const parsed = JSON.parse(params.getAll('fieldValues')[0]);
t.deepEqual(parsed, { resourceKey: '**/*.js', ruleKey: 'squid:S001' });
});

test('buildSettingsParams includes empty string value', t => {
const params = buildSettingsParams({ key: 'sonar.test.inclusions', component: 'proj', value: '' });
t.is(params.get('value'), '');
});

test('buildSettingsParams omits undefined value', t => {
const params = buildSettingsParams({ key: 'sonar.exclusions', component: 'proj', value: undefined, values: ['a'] });
t.is(params.get('value'), null);
t.deepEqual(params.getAll('values'), ['a']);
});

test('buildSettingsParams omits null value', t => {
const params = buildSettingsParams({ key: 'sonar.exclusions', component: 'proj', value: null, values: ['a'] });
t.is(params.get('value'), null);
});

// ============================================================================
// dispatchSettingToApi
// ============================================================================

test('dispatchSettingToApi dispatches scalar value', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.cpd.enabled', value: 'true' }, 'proj');
t.true(result);
t.deepEqual(client.setProjectSetting.firstCall.args, ['sonar.cpd.enabled', { value: 'true' }, 'proj']);
});

test('dispatchSettingToApi dispatches multi-value array', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.exclusions', values: ['a', 'b'] }, 'proj');
t.true(result);
t.deepEqual(client.setProjectSetting.firstCall.args, ['sonar.exclusions', { values: ['a', 'b'] }, 'proj']);
});

test('dispatchSettingToApi dispatches fieldValues', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const fvs = [{ resourceKey: '**/*.js', ruleKey: 'squid:S001' }];
const result = await dispatchSettingToApi(client, { key: 'sonar.multi', fieldValues: fvs }, 'proj');
t.true(result);
t.deepEqual(client.setProjectSetting.firstCall.args, ['sonar.multi', { fieldValues: fvs }, 'proj']);
});

test('dispatchSettingToApi dispatches empty string value (not skipped)', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.test.inclusions', value: '' }, 'proj');
t.true(result);
t.deepEqual(client.setProjectSetting.firstCall.args, ['sonar.test.inclusions', { value: '' }, 'proj']);
});

test('dispatchSettingToApi prefers value over values', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'k', value: 'scalar', values: ['a'] }, 'proj');
t.true(result);
t.deepEqual(client.setProjectSetting.firstCall.args, ['k', { value: 'scalar' }, 'proj']);
});

test('dispatchSettingToApi returns false for empty setting', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.empty' }, 'proj');
t.false(result);
t.is(client.setProjectSetting.callCount, 0);
});

test('dispatchSettingToApi returns false for empty values array', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.empty', values: [] }, 'proj');
t.false(result);
t.is(client.setProjectSetting.callCount, 0);
});

test('dispatchSettingToApi returns false for empty fieldValues array', async t => {
const client = { setProjectSetting: sinon.stub().resolves() };
const result = await dispatchSettingToApi(client, { key: 'sonar.empty', fieldValues: [] }, 'proj');
t.false(result);
t.is(client.setProjectSetting.callCount, 0);
});