#!/usr/bin/env npx tsx /** * Consolidate/merge Jira projects after CF-762 migration. * * Uses Jira Cloud Bulk Move API (POST /rest/api/3/bulk/issues/move) * to move all issues from SOURCE to TARGET project, then updates * task_migration_map and tasks table in PostgreSQL. * * Usage: * npx tsx scripts/consolidate-projects.ts --from LIT --to LITE [--dry-run] [--delete-source] * npx tsx scripts/consolidate-projects.ts --batch tier1 [--dry-run] [--delete-source] * npx tsx scripts/consolidate-projects.ts --batch all [--dry-run] [--delete-source] */ import pg from 'pg'; import dotenv from 'dotenv'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: join(__dirname, '..', '.env'), override: true }); const JIRA_URL = process.env.JIRA_URL || 'https://agiliton.atlassian.net'; const JIRA_USER = process.env.JIRA_USERNAME || ''; const JIRA_TOKEN = process.env.JIRA_API_TOKEN || ''; const JIRA_AUTH = Buffer.from(`${JIRA_USER}:${JIRA_TOKEN}`).toString('base64'); const pool = new pg.Pool({ host: process.env.POSTGRES_HOST || 'postgres.agiliton.internal', port: parseInt(process.env.POSTGRES_PORT || '5432'), database: 'agiliton', user: 'agiliton', password: 'QtqiwCOAUpQNF6pjzOMAREzUny2bY8V1', max: 3, }); const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const DELETE_SOURCE = args.includes('--delete-source'); const FROM = args.find((_, i) => args[i - 1] === '--from') || ''; const TO = args.find((_, i) => args[i - 1] === '--to') || ''; const BATCH = args.find((_, i) => args[i - 1] === '--batch') || ''; const DELAY_MS = 700; const MAX_RETRIES = 5; const POLL_INTERVAL_MS = 2000; const POLL_TIMEOUT_MS = 120000; // Batch definitions — LIT already moved manually during testing const TIER1: Array<[string, string]> = [ ['CARD', 'CS'], ['TES', 'TS'], ['DA', 'DB'], ['AF', 'AFNE'], ]; const TIER2: Array<[string, string]> = [ ['RUBI', 'RUB'], ['ET', 'TG'], ['ZORK', 'ZOS'], ]; const TIER3: Array<[string, string]> = [ ['IS', 'INFR'], ['CLN', 'INFR'], ['TOOLS', 'INFR'], ]; interface JiraIssue { key: string; id: string; fields: { summary: string; issuetype: { id: string; name: string }; status: { name: string }; }; } function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } async function jiraFetch(path: string, options: RequestInit = {}): Promise { const url = `${JIRA_URL}/rest/api/3${path}`; return fetch(url, { ...options, headers: { 'Authorization': `Basic ${JIRA_AUTH}`, 'Content-Type': 'application/json', 'Accept': 'application/json', ...options.headers, }, }); } async function jiraFetchWithRetry(path: string, options: RequestInit = {}): Promise { let lastResponse: Response | null = null; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { await delay(DELAY_MS); const response = await jiraFetch(path, options); lastResponse = response; if (response.status === 429 || response.status >= 500) { if (attempt < MAX_RETRIES) { const retryAfter = response.headers.get('Retry-After'); const backoffMs = retryAfter ? parseInt(retryAfter) * 1000 : DELAY_MS * Math.pow(2, attempt + 1); console.warn(` [RETRY] ${response.status}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${backoffMs}ms`); await delay(backoffMs); continue; } } return response; } return lastResponse!; } // Get project ID for a project key async function getProjectId(key: string): Promise { const res = await jiraFetchWithRetry(`/project/${key}`); if (!res.ok) return null; const data = await res.json() as { id: string }; return data.id; } // Get all issues in a project (v3 GET /search/jql) async function getAllIssues(projectKey: string): Promise { const issues: JiraIssue[] = []; let startAt = 0; while (true) { const jql = encodeURIComponent(`project="${projectKey}" ORDER BY key ASC`); const res = await jiraFetchWithRetry(`/search/jql?jql=${jql}&maxResults=100&startAt=${startAt}&fields=summary,issuetype,status`); if (!res.ok) { console.error(` Failed to search ${projectKey}: ${res.status} ${await res.text()}`); break; } const data = await res.json() as { total?: number; issues: JiraIssue[]; isLast?: boolean }; issues.push(...data.issues); startAt += data.issues.length; if (data.isLast || (data.total !== undefined && startAt >= data.total) || data.issues.length === 0) break; } return issues; } // Get issue type IDs available in a project async function getProjectIssueTypes(projectKey: string): Promise> { const res = await jiraFetchWithRetry(`/project/${projectKey}/statuses`); if (!res.ok) return new Map(); const types = await res.json() as Array<{ id: string; name: string }>; return new Map(types.map(t => [t.name, t.id])); } // Bulk move issues using Jira Cloud API // Key format: "targetProjectId,targetIssueTypeId" async function bulkMoveIssues( issueKeys: string[], targetProjectId: string, targetIssueTypeId: string, ): Promise<{ taskId: string } | null> { const mappingKey = `${targetProjectId},${targetIssueTypeId}`; const body = { sendBulkNotification: false, targetToSourcesMapping: { [mappingKey]: { inferFieldDefaults: true, inferStatusDefaults: true, inferSubtaskTypeDefault: true, issueIdsOrKeys: issueKeys, }, }, }; const res = await jiraFetchWithRetry('/bulk/issues/move', { method: 'POST', body: JSON.stringify(body), }); if (!res.ok) { const errorBody = await res.text(); console.error(` FAIL bulk move: ${res.status} ${errorBody}`); return null; } const data = await res.json() as { taskId: string }; return data; } // Poll a Jira async task until complete async function pollTask(taskId: string): Promise<{ success: number[]; failed: Record } | null> { const start = Date.now(); while (Date.now() - start < POLL_TIMEOUT_MS) { await delay(POLL_INTERVAL_MS); const res = await jiraFetchWithRetry(`/task/${taskId}`); if (!res.ok) { console.error(` FAIL poll task ${taskId}: ${res.status}`); return null; } const data = await res.json() as { status: string; progress: number; result?: { successfulIssues: number[]; failedIssues: Record; totalIssueCount: number }; }; if (data.status === 'COMPLETE') { return { success: data.result?.successfulIssues || [], failed: data.result?.failedIssues || {}, }; } if (data.status === 'FAILED' || data.status === 'CANCELLED') { console.error(` Task ${taskId} ${data.status}`); return null; } // Still running if (data.progress > 0) { process.stdout.write(`\r Task ${taskId}: ${data.progress}%`); } } console.error(` Task ${taskId} timed out after ${POLL_TIMEOUT_MS / 1000}s`); return null; } // Get issue key by numeric ID async function getIssueKey(issueId: number): Promise { const res = await jiraFetchWithRetry(`/issue/${issueId}?fields=key`); if (!res.ok) return null; const data = await res.json() as { key: string }; return data.key; } // Delete a Jira project async function deleteProject(key: string): Promise { if (DRY_RUN) { console.log(` [DRY] Would delete project ${key}`); return true; } const res = await jiraFetch(`/project/${key}?enableUndo=false`, { method: 'DELETE' }); return res.status === 204; } // Consolidate one pair async function consolidate(from: string, to: string): Promise<{ moved: number; failed: number }> { console.log(`\n=== Consolidating ${from} → ${to} ===`); // Get project IDs const fromProjectId = await getProjectId(from); const toProjectId = await getProjectId(to); if (!fromProjectId) { console.error(` Source project ${from} does not exist in Jira. Skipping.`); return { moved: 0, failed: 0 }; } if (!toProjectId) { console.error(` Target project ${to} does not exist in Jira. Skipping.`); return { moved: 0, failed: 0 }; } // Get target project issue types const targetTypes = await getProjectIssueTypes(to); console.log(` Target ${to} (id=${toProjectId}) issue types: ${Array.from(targetTypes.entries()).map(([n, id]) => `${n}=${id}`).join(', ')}`); // Get all issues from source const issues = await getAllIssues(from); console.log(` Found ${issues.length} issues in ${from}`); if (issues.length === 0) { console.log(` Nothing to move.`); if (DELETE_SOURCE) { console.log(` Deleting empty source project ${from}...`); const deleted = await deleteProject(from); console.log(` ${deleted ? 'Deleted' : 'FAILED to delete'} ${from}`); } return { moved: 0, failed: 0 }; } if (DRY_RUN) { console.log(` [DRY] Would move ${issues.length} issues:`); for (const issue of issues) { console.log(` ${issue.key} [${issue.fields.issuetype.name}] ${issue.fields.status.name}: ${issue.fields.summary.substring(0, 60)}`); } // Still do DB updates in dry run? No. return { moved: issues.length, failed: 0 }; } // Build old issue ID → old key map (for tracking after move) const idToOldKey = new Map(); for (const issue of issues) { idToOldKey.set(parseInt(issue.id), issue.key); } // Group issues by issue type for bulk move const byType = new Map(); for (const issue of issues) { const typeName = issue.fields.issuetype.name; const targetTypeId = targetTypes.get(typeName); if (!targetTypeId) { // Fall back to Task if type doesn't exist in target const fallbackId = targetTypes.get('Task'); if (!fallbackId) { console.error(` No matching type for ${typeName} in ${to}, and no Task fallback. Skipping ${issue.key}`); continue; } console.warn(` [WARN] ${issue.key} type ${typeName} not in target, using Task (${fallbackId})`); const group = byType.get('Task') || { typeId: fallbackId, typeName: 'Task', keys: [] }; group.keys.push(issue.key); byType.set('Task', group); } else { const group = byType.get(typeName) || { typeId: targetTypeId, typeName, keys: [] }; group.keys.push(issue.key); byType.set(typeName, group); } } let totalMoved = 0; let totalFailed = 0; const keyMapping = new Map(); // old key → new key // Move each type group for (const [typeName, group] of byType) { console.log(` Moving ${group.keys.length} ${typeName} issues...`); const result = await bulkMoveIssues(group.keys, toProjectId, group.typeId); if (!result) { totalFailed += group.keys.length; continue; } console.log(` Waiting for task ${result.taskId}...`); const taskResult = await pollTask(result.taskId); process.stdout.write('\r'); if (!taskResult) { totalFailed += group.keys.length; continue; } const failedCount = Object.keys(taskResult.failed).length; console.log(` Task complete: ${taskResult.success.length} moved, ${failedCount} failed`); totalMoved += taskResult.success.length; totalFailed += failedCount; // Resolve new keys for moved issues for (const movedId of taskResult.success) { const oldKey = idToOldKey.get(movedId); if (!oldKey) continue; const newKey = await getIssueKey(movedId); if (newKey) { keyMapping.set(oldKey, newKey); } } } console.log(` Total moved: ${totalMoved}, failed: ${totalFailed}`); console.log(` Key mappings resolved: ${keyMapping.size}`); // Log all mappings for (const [oldKey, newKey] of keyMapping) { console.log(` ${oldKey} → ${newKey}`); } // Update PostgreSQL if (totalMoved > 0) { console.log(` Updating PostgreSQL...`); // 1. Update task_migration_map with new Jira keys let mapUpdated = 0; for (const [oldKey, newKey] of keyMapping) { const res = await pool.query( `UPDATE task_migration_map SET jira_issue_key = $1, migrated_at = NOW() WHERE jira_issue_key = $2`, [newKey, oldKey] ); if ((res.rowCount || 0) > 0) { mapUpdated++; } else { // Try where old_task_id matches (identity mapping case) const res2 = await pool.query( `UPDATE task_migration_map SET jira_issue_key = $1, migrated_at = NOW() WHERE old_task_id = $2`, [newKey, oldKey] ); if ((res2.rowCount || 0) > 0) mapUpdated++; } } console.log(` task_migration_map: ${mapUpdated} entries updated`); // 2. Update tasks table: change project from SOURCE to TARGET const taskUpdate = await pool.query( `UPDATE tasks SET project = $1 WHERE project = $2`, [to, from] ); console.log(` tasks: ${taskUpdate.rowCount} rows (project ${from} → ${to})`); // 3. Update epics table try { const epicUpdate = await pool.query( `UPDATE epics SET project = $1 WHERE project = $2`, [to, from] ); console.log(` epics: ${epicUpdate.rowCount} rows`); } catch { /* epics may not reference this project */ } // 4. Update FK references that use Jira keys for (const [oldKey, newKey] of keyMapping) { try { await pool.query(`UPDATE memories SET jira_issue_key = $1 WHERE jira_issue_key = $2`, [newKey, oldKey]); } catch {} try { await pool.query(`UPDATE session_context SET jira_issue_key = $1 WHERE jira_issue_key = $2`, [newKey, oldKey]); } catch {} try { await pool.query(`UPDATE sessions SET jira_issue_key = $1 WHERE jira_issue_key = $2`, [newKey, oldKey]); } catch {} try { await pool.query(`UPDATE task_commits SET jira_issue_key = $1 WHERE jira_issue_key = $2`, [newKey, oldKey]); } catch {} try { await pool.query(`UPDATE deployments SET jira_issue_key = $1 WHERE jira_issue_key = $2`, [newKey, oldKey]); } catch {} } console.log(` FK references updated`); // 5. Update projects table references try { await pool.query(`DELETE FROM project_archives WHERE project_key = $1`, [from]); } catch {} } // Delete source project if requested if (DELETE_SOURCE) { const remaining = await getAllIssues(from); if (remaining.length === 0) { console.log(` Deleting empty source project ${from}...`); const deleted = await deleteProject(from); console.log(` ${deleted ? 'Deleted' : 'FAILED to delete'} ${from}`); } else { console.log(` Source ${from} still has ${remaining.length} issues, not deleting.`); } } return { moved: totalMoved, failed: totalFailed }; } async function main() { console.log('=== Project Consolidation (CF-762 Post-Migration) ==='); console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); console.log(`Delete source: ${DELETE_SOURCE ? 'yes' : 'no'}`); console.log(''); if (!JIRA_USER || !JIRA_TOKEN) { console.error('Missing JIRA_USERNAME or JIRA_API_TOKEN'); process.exit(1); } let pairs: Array<[string, string]> = []; if (BATCH) { switch (BATCH) { case 'tier1': pairs = TIER1; break; case 'tier2': pairs = TIER2; break; case 'tier3': pairs = TIER3; break; case 'all': pairs = [...TIER1, ...TIER2, ...TIER3]; break; default: console.error(`Unknown batch: ${BATCH}. Use: tier1, tier2, tier3, all`); process.exit(1); } } else if (FROM && TO) { pairs = [[FROM, TO]]; } else { console.error('Usage:'); console.error(' npx tsx scripts/consolidate-projects.ts --from LIT --to LITE [--dry-run] [--delete-source]'); console.error(' npx tsx scripts/consolidate-projects.ts --batch tier1|tier2|tier3|all [--dry-run] [--delete-source]'); process.exit(1); } console.log(`Pairs to consolidate (${pairs.length}):`); for (const [from, to] of pairs) { console.log(` ${from} → ${to}`); } console.log(''); let totalMoved = 0; let totalFailed = 0; for (const [from, to] of pairs) { const result = await consolidate(from, to); totalMoved += result.moved; totalFailed += result.failed; } console.log('\n=== Consolidation Summary ==='); console.log(`Total moved: ${totalMoved}`); console.log(`Total failed: ${totalFailed}`); console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); await pool.end(); } main().catch(err => { console.error('Consolidation failed:', err); process.exit(1); });