#!/usr/bin/env npx tsx /** * Validate CF-762 migration integrity. * Checks: Jira issue counts vs DB, statuses, checklists, epic links, FK references. * * Usage: npx tsx scripts/validate-migration.ts [--project CF] [--verbose] */ 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 PROJECT_FILTER = args.find((_, i) => args[i - 1] === '--project') || ''; const VERBOSE = args.includes('--verbose'); const DELAY_MS = 700; function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } async function jiraFetch(path: string): Promise { await delay(DELAY_MS); return fetch(`${JIRA_URL}/rest/api/3${path}`, { headers: { 'Authorization': `Basic ${JIRA_AUTH}`, 'Accept': 'application/json', }, }); } // v3 search/jql uses cursor pagination, no total. Count by paging through. async function jiraIssueCount(projectKey: string): Promise { let count = 0; let nextPageToken: string | undefined; while (true) { const jql = encodeURIComponent(`project="${projectKey}"`); let url = `/search/jql?jql=${jql}&maxResults=100&fields=summary`; if (nextPageToken) url += `&nextPageToken=${encodeURIComponent(nextPageToken)}`; const res = await jiraFetch(url); if (!res.ok) return -1; const data = await res.json() as { issues: unknown[]; nextPageToken?: string; isLast?: boolean }; count += data.issues.length; if (data.isLast || !data.nextPageToken || data.issues.length === 0) break; nextPageToken = data.nextPageToken; } return count; } async function jiraPlaceholderCount(): Promise { const jql = encodeURIComponent(`labels = "migration-placeholder"`); const res = await jiraFetch(`/search/jql?jql=${jql}&maxResults=0`); if (!res.ok) return -1; const data = await res.json() as { total?: number }; return data.total ?? -1; } async function spotCheckChecklists(projectKey: string): Promise<{ total: number; withChecklist: number }> { const jql = encodeURIComponent(`project="${projectKey}" AND labels = "migrated-from-task-mcp" ORDER BY key ASC`); const res = await jiraFetch(`/search/jql?jql=${jql}&maxResults=3&fields=summary,customfield_10091`); if (!res.ok) return { total: 0, withChecklist: 0 }; const data = await res.json() as { issues: Array<{ key: string; fields: Record }> }; let withChecklist = 0; for (const issue of data.issues) { if (issue.fields.customfield_10091) withChecklist++; } return { total: data.issues.length, withChecklist }; } async function spotCheckStatuses(projectKey: string): Promise> { const counts: Record = {}; const jql = encodeURIComponent(`project="${projectKey}" AND labels = "migrated-from-task-mcp"`); const res = await jiraFetch(`/search/jql?jql=${jql}&maxResults=100&fields=status`); if (!res.ok) return counts; const data = await res.json() as { issues: Array<{ fields: { status: { name: string } } }> }; for (const issue of data.issues) { const status = issue.fields.status.name; counts[status] = (counts[status] || 0) + 1; } return counts; } async function spotCheckEpicLinks(projectKey: string): Promise<{ total: number; withParent: number }> { const jql = encodeURIComponent(`project="${projectKey}" AND issuetype != Epic AND labels = "migrated-from-task-mcp" ORDER BY key ASC`); const res = await jiraFetch(`/search/jql?jql=${jql}&maxResults=5&fields=parent`); if (!res.ok) return { total: 0, withParent: 0 }; const data = await res.json() as { issues: Array<{ key: string; fields: Record }> }; let withParent = 0; for (const issue of data.issues) { if (issue.fields?.parent) withParent++; } return { total: data.issues.length, withParent }; } async function main() { console.log('=== CF-762 Migration Validation ===\n'); // 1. Per-project Jira vs DB counts console.log('1. Per-project issue counts (Jira vs DB):'); console.log(' Project | Jira | DB Tasks | DB Migration Map | Match'); console.log(' --------|------|----------|-----------------|------'); const dbProjects = await pool.query( `SELECT p.key, COUNT(DISTINCT t.id) as task_count, COUNT(DISTINCT m.old_task_id) as map_count FROM projects p LEFT JOIN tasks t ON t.project = p.key LEFT JOIN task_migration_map m ON m.old_task_id = t.id WHERE p.key ~ '^[A-Z]{2,5}$' ${PROJECT_FILTER ? `AND p.key = '${PROJECT_FILTER}'` : ''} GROUP BY p.key HAVING COUNT(t.id) > 0 ORDER BY p.key` ); let mismatches = 0; for (const row of dbProjects.rows) { const jiraCount = await jiraIssueCount(row.key); const match = jiraCount >= parseInt(row.task_count) ? 'OK' : 'MISMATCH'; if (match !== 'OK') mismatches++; console.log(` ${row.key.padEnd(7)} | ${String(jiraCount).padStart(4)} | ${String(row.task_count).padStart(8)} | ${String(row.map_count).padStart(15)} | ${match}`); } console.log(`\n Mismatches: ${mismatches}\n`); // 2. Spot-check checklists (3 projects) console.log('2. Checklist spot-check:'); const checkProjects = PROJECT_FILTER ? [PROJECT_FILTER] : ['CF', 'OWUI', 'WHMCS']; for (const pk of checkProjects) { const result = await spotCheckChecklists(pk); console.log(` ${pk}: ${result.withChecklist}/${result.total} issues have checklists`); } console.log(''); // 3. Status distribution spot-check console.log('3. Status distribution spot-check:'); const statusProjects = PROJECT_FILTER ? [PROJECT_FILTER] : ['CF', 'GB', 'RUB']; for (const pk of statusProjects) { const statuses = await spotCheckStatuses(pk); console.log(` ${pk}: ${Object.entries(statuses).map(([s, c]) => `${s}=${c}`).join(', ')}`); } console.log(''); // 4. Epic→Task parent links console.log('4. Epic→Task parent links spot-check:'); const epicProjects = PROJECT_FILTER ? [PROJECT_FILTER] : ['CF', 'RUB', 'OWUI']; for (const pk of epicProjects) { const result = await spotCheckEpicLinks(pk); console.log(` ${pk}: ${result.withParent}/${result.total} tasks have parent epic`); } console.log(''); // 5. NULL FK references console.log('5. NULL FK references (should be from unmigrated/deleted projects):'); const nullChecks = [ { table: 'memories', col: 'jira_issue_key', fk: 'task_id' }, { table: 'session_context', col: 'jira_issue_key', fk: 'current_task_id' }, { table: 'task_commits', col: 'jira_issue_key', fk: 'task_id' }, ]; for (const { table, col, fk } of nullChecks) { try { const res = await pool.query( `SELECT COUNT(*) as cnt FROM ${table} WHERE ${fk} IS NOT NULL AND ${col} IS NULL` ); const count = parseInt(res.rows[0].cnt); if (count > 0) { console.log(` ${table}: ${count} rows with task_id but no jira_issue_key`); if (VERBOSE) { const details = await pool.query( `SELECT ${fk} FROM ${table} WHERE ${fk} IS NOT NULL AND ${col} IS NULL LIMIT 5` ); for (const d of details.rows) { console.log(` - ${d[fk]}`); } } } else { console.log(` ${table}: OK (0 NULL refs)`); } } catch (e: any) { console.log(` ${table}: ${e.message}`); } } console.log(''); // 6. Migration map total const mapTotal = await pool.query('SELECT COUNT(*) as cnt FROM task_migration_map'); console.log(`6. Total migration mappings: ${mapTotal.rows[0].cnt}`); // 7. Placeholder count in Jira const placeholders = await jiraPlaceholderCount(); console.log(`7. Placeholder issues in Jira (label=migration-placeholder): ${placeholders}`); // 8. Consolidated projects check — should no longer exist console.log('\n8. Deleted source projects (should be gone from Jira):'); const deletedProjects = ['LIT', 'CARD', 'TES', 'DA', 'AF', 'RUBI', 'ET', 'ZORK', 'IS', 'CLN', 'TOOLS']; for (const pk of deletedProjects) { const res = await jiraFetch(`/project/${pk}`); const status = res.ok ? 'STILL EXISTS' : 'Gone'; console.log(` ${pk}: ${status}`); } // 9. Remaining projects console.log('\n9. Current Jira projects:'); const projRes = await jiraFetch('/project'); if (projRes.ok) { const projects = await projRes.json() as Array<{ key: string; name: string }>; console.log(` Total: ${projects.length}`); for (const p of projects.sort((a, b) => a.key.localeCompare(b.key))) { const count = await jiraIssueCount(p.key); console.log(` ${p.key.padEnd(8)} ${String(count).padStart(4)} issues - ${p.name}`); } } await pool.end(); console.log('\n=== Validation Complete ==='); } main().catch(err => { console.error(err); process.exit(1); });