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
5 changes: 2 additions & 3 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,12 @@ An issue is considered to have **manual changes** if any of:
- `fetchSqChangelogs(sqIssues, sqClient, concurrency)` — batch-fetches changelogs via `mapConcurrent`, returns `Map<issueKey, changelog[]>`
- `hasManualChanges(issue, changelog)` — pure function, returns `boolean`

The pre-fetched changelogs are passed through to `syncIssueStatus` via a `changelogMap` to avoid redundant per-issue changelog API calls.
The pre-fetched changelogs are used only to evaluate `hasManualChanges` during pre-filtering. Status sync derives the target SC transition from the issue's *current* SQ status (not the changelog history), so changelogs are not propagated past the filter step.

**Performance test:** `test/utils/issue-sync.test.js` includes regression tests that verify the filter ratio stays within tolerance (<=10% of issues pass through) and that filtering 50K issues completes in <1 second.

**Rules:**
- Always pass `changelogMap` through from orchestrator to per-issue sync functions
- The `preloadedChangelog` parameter in `syncIssueStatus` uses `??` (nullish coalescing) so it falls back to fetching if the caller doesn't provide preloaded data
- `syncIssueStatus(scIssue, sqIssue, client)` applies a single transition derived from the SQ issue's current state — it does not replay changelog history
- The `stats.filtered` counter tracks how many issues were skipped by the pre-filter

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import logger from '../../../../../../shared/utils/logger.js';
import { extractTransitionsFromChangelog, getFallbackTransition } from './transition-mapping.js';
import { getFallbackTransition } from './transition-mapping.js';

// -------- Sync Issue Status --------

export async function syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog) {
/**
* Sync the SC issue's status to match the current SQ issue status.
* Applies a single transition derived from the SQ issue's current state.
*/
export async function syncIssueStatus(scIssue, sqIssue, client) {
if (scIssue.status === sqIssue.status) return false;
if (sqClient) {
try {
const changelog = preloadedChangelog ?? await sqClient.getIssueChangelog(sqIssue.key);
const transitions = extractTransitionsFromChangelog(changelog);
if (transitions.length === 0) return await applyFallbackTransition(scIssue, sqIssue, client);
let applied = false;
for (const transition of transitions) {
try { await client.transitionIssue(scIssue.key, transition); applied = true; }
catch (error) { logger.debug(`Failed to apply transition '${transition}' on issue ${scIssue.key}: ${error.message}`); }
}
return applied;
} catch (error) {
logger.debug(`Failed to fetch changelog for issue ${sqIssue.key}, falling back: ${error.message}`);
return await applyFallbackTransition(scIssue, sqIssue, client);
}
}
return await applyFallbackTransition(scIssue, sqIssue, client);
return applyFallbackTransition(scIssue, sqIssue, client);
}

async function applyFallbackTransition(scIssue, sqIssue, client) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { addSourceLink } from './add-source-link.js';
// -------- Sync Single Issue --------

export async function syncSingleIssue(sqIssue, scIssue, client, sqClient, userMappings, stats, changelogMap = new Map()) {
const preloadedChangelog = changelogMap.get(sqIssue.key);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client);
if (transitioned) stats.transitioned++;
await syncIssueAssignment(sqIssue, scIssue, client, userMappings, stats);
await syncIssueComments(sqIssue, scIssue, client, stats);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function getFallbackTransition(sqIssue) {
if (sqIssue.resolution === 'WONTFIX' || sqIssue.status === 'WONTFIX') return 'wontfix';
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED': case 'CLOSED': return 'resolve';
case 'RESOLVED': case 'CLOSED': case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
// ACCEPTED in SQ 10.4+ maps to 'wontfix' in SonarCloud
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
import { extractTransitionsFromChangelog } from './extract-transitions.js';
import { getFallbackTransition } from './get-fallback-transition.js';
import logger from '../../../../../../shared/utils/logger.js';

// -------- Main Logic --------
// -------- Sync Issue Status --------

/**
* Sync issue status by replaying the full changelog transition sequence.
* Sync the SC issue's status to match the current SQ issue status.
* Applies a single transition derived from the SQ issue's current state.
*/
export async function syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog) {
export async function syncIssueStatus(scIssue, sqIssue, client) {
if (scIssue.status === sqIssue.status) return false;

if (sqClient) {
try {
const changelog = preloadedChangelog ?? await sqClient.getIssueChangelog(sqIssue.key);
const transitions = extractTransitionsFromChangelog(changelog);
if (transitions.length === 0) return await applyFallbackTransition(scIssue, sqIssue, client);

let applied = false;
for (const transition of transitions) {
try { await client.transitionIssue(scIssue.key, transition); applied = true; }
catch (e) { logger.debug(`Failed to apply transition '${transition}' on issue ${scIssue.key}: ${e.message}`); }
}
return applied;
} catch (error) {
logger.debug(`Failed to fetch changelog for issue ${sqIssue.key}, falling back: ${error.message}`);
return await applyFallbackTransition(scIssue, sqIssue, client);
}
}

return await applyFallbackTransition(scIssue, sqIssue, client);
return applyFallbackTransition(scIssue, sqIssue, client);
}

// -------- Helper Functions --------

async function applyFallbackTransition(scIssue, sqIssue, client) {
const transition = getFallbackTransition(sqIssue);
if (!transition) return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import logger from '../../../../../../shared/utils/logger.js';
*/
export async function syncSingleIssue(sqIssue, scIssue, client, sqClient, userMappings, stats, changelogMap = new Map()) {
try {
const preloadedChangelog = changelogMap.get(sqIssue.key);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client);
if (transitioned) stats.transitioned++;

await syncIssueAssignment(sqIssue, scIssue, client, userMappings, stats);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,12 @@
import logger from '../../../../../../shared/utils/logger.js';
import { extractTransitionsFromChangelog } from './extract-transitions.js';
import { applyFallbackTransition } from './apply-fallback-transition.js';

// -------- Sync Issue Status --------

/**
* Sync issue status by replaying the full changelog transition sequence.
* Falls back to single-transition when no SQ client is provided.
* Sync the SC issue's status to match the current SQ issue status.
* Applies a single transition derived from the SQ issue's current state.
*/
export async function syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog) {
export async function syncIssueStatus(scIssue, sqIssue, client) {
if (scIssue.status === sqIssue.status) return false;

if (sqClient) {
try {
const changelog = preloadedChangelog ?? await sqClient.getIssueChangelog(sqIssue.key);
const transitions = extractTransitionsFromChangelog(changelog);

if (transitions.length === 0) {
return await applyFallbackTransition(scIssue, sqIssue, client);
}

let applied = false;
for (const transition of transitions) {
try {
await client.transitionIssue(scIssue.key, transition);
applied = true;
} catch (error) {
logger.debug(`Failed to apply transition '${transition}' on issue ${scIssue.key}: ${error.message}`);
}
}
return applied;
} catch (error) {
logger.debug(`Failed to fetch changelog for issue ${sqIssue.key}, falling back: ${error.message}`);
return await applyFallbackTransition(scIssue, sqIssue, client);
}
}

return await applyFallbackTransition(scIssue, sqIssue, client);
return applyFallbackTransition(scIssue, sqIssue, client);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import { addSourceLink } from './add-source-link.js';
/** Sync a single matched issue pair (status, assignment, comments, tags, source link). */
export async function syncOneIssue({ sqIssue, scIssue }, client, sqClient, userMappings, stats, changelogMap = new Map()) {
try {
const preloadedChangelog = changelogMap.get(sqIssue.key);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client);
if (transitioned) stats.transitioned++;

await syncIssueAssignment(sqIssue, scIssue, client, userMappings, stats);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function syncIssues(projectKey, sqIssues, client, options = {}) {
if (matchedPairs.length >= PARALLEL_THRESHOLD) {
const scConfig = { baseURL: client.baseURL, token: client.token, organization: client.organization, projectKey };
const sqClientConfig = sqClient ? { baseURL: sqClient.baseURL, token: sqClient.token, projectKey: sqClient.projectKey } : null;
const mergedStats = await parallelSyncIssues(matchedPairs, changelogMap, scConfig, sqClientConfig, userMappings);
const mergedStats = await parallelSyncIssues(matchedPairs, scConfig, sqClientConfig, userMappings);
Object.assign(stats, mergedStats);
} else {
logger.info(`Syncing ${matchedPairs.length} issues with concurrency=${concurrency}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function getFallbackTransition(sqIssue) {
switch (sqIssue.status) {
case 'CONFIRMED': return 'confirm';
case 'RESOLVED':
case 'CLOSED': return 'resolve';
case 'CLOSED':
case 'FIXED': return 'resolve';
case 'ACCEPTED': return 'wontfix';
case 'REOPENED': return 'reopen';
default: return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
import logger from '../../../../../../shared/utils/logger.js';
import { extractTransitionsFromChangelog } from './extract-transitions-from-changelog.js';
import { applyFallbackTransition } from './apply-fallback-transition.js';

// -------- Sync Issue Status via Changelog Replay --------
// -------- Sync Issue Status --------

export async function syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog) {
/**
* Sync the SC issue's status to match the current SQ issue status.
* Applies a single transition derived from the SQ issue's current state.
*/
export async function syncIssueStatus(scIssue, sqIssue, client) {
if (scIssue.status === sqIssue.status) return false;

if (sqClient) {
try {
const changelog = preloadedChangelog ?? await sqClient.getIssueChangelog(sqIssue.key);
const transitions = extractTransitionsFromChangelog(changelog);
if (transitions.length === 0) return applyFallbackTransition(scIssue, sqIssue, client);

let applied = false;
for (const transition of transitions) {
try {
await client.transitionIssue(scIssue.key, transition);
applied = true;
} catch (error) {
logger.debug(`Failed to apply transition '${transition}' on issue ${scIssue.key}: ${error.message}`);
}
}
return applied;
} catch (error) {
logger.debug(`Failed to fetch changelog for issue ${sqIssue.key}, falling back: ${error.message}`);
return applyFallbackTransition(scIssue, sqIssue, client);
}
}

return applyFallbackTransition(scIssue, sqIssue, client);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ export async function syncIssues(projectKey, sqIssues, client, options = {}) {
matchedPairs,
async ({ sqIssue, scIssue }) => {
try {
const preloadedChangelog = changelogMap.get(sqIssue.key);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client, sqClient, preloadedChangelog);
const transitioned = await syncIssueStatus(scIssue, sqIssue, client);
if (transitioned) stats.transitioned++;
await syncIssueAssignment(scIssue, sqIssue, client, stats, userMappings);
await syncIssueCommentsAndTags(scIssue, sqIssue, client, stats, sqClient);
Expand Down
Loading