- 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>
233 lines
9.3 KiB
TypeScript
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); });
|