Files
session-mcp/scripts/validate-migration.ts
Christian Gick 1227e5b339 feat(CF-762): Complete Jira migration - consolidate projects, cleanup
- Remove task CRUD/epic/search/relation/version tools (moved to Jira)
- Add migration scripts: migrate-tasks-to-jira, jira-admin, prepare-all-projects
- Add consolidate-projects.ts for merging duplicate Jira projects
- Add validate-migration.ts for post-migration integrity checks
- Add jira_issue_key columns migration (030)
- Consolidate 11 duplicate projects (LIT→LITE, CARD→CS, etc.)
- Delete 92 placeholder issues, 11 empty source projects
- Remove SG project completely
- 2,798 tasks migrated across 46 Jira projects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:33:49 +02:00

233 lines
9.3 KiB
TypeScript

#!/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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function jiraFetch(path: string): Promise<Response> {
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<number> {
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<number> {
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<string, unknown> }> };
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<Record<string, number>> {
const counts: Record<string, number> = {};
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<string, unknown> }> };
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); });