#!/usr/bin/env npx tsx /** * Prepare all projects for exact-key migration (CF-762) * For each project: delete → recreate → assign shared issue type scheme * Then the migration script can run for all projects at once. * * Usage: * npx tsx scripts/prepare-all-projects.ts [--dry-run] [--exclude CF] */ 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 { Pool } = pg; const JIRA_URL = process.env.JIRA_URL || 'https://agiliton.atlassian.net'; const JIRA_USER = process.env.JIRA_USERNAME || process.env.JIRA_EMAIL || ''; const JIRA_TOKEN = process.env.JIRA_API_TOKEN || ''; const JIRA_AUTH = Buffer.from(`${JIRA_USER}:${JIRA_TOKEN}`).toString('base64'); const SHARED_SCHEME_ID = '10329'; // Agiliton Software Issue Type Scheme const pool = new Pool({ host: process.env.POSTGRES_HOST || 'postgres.agiliton.internal', port: 5432, database: 'agiliton', user: 'agiliton', password: 'QtqiwCOAUpQNF6pjzOMAREzUny2bY8V1', max: 3, }); const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const excludeIdx = args.indexOf('--exclude'); const EXCLUDE = excludeIdx >= 0 ? args[excludeIdx + 1]?.split(',') || [] : []; function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } async function jiraFetch(path: string, options: RequestInit = {}): Promise { return fetch(`${JIRA_URL}/rest/api/3${path}`, { ...options, headers: { Authorization: `Basic ${JIRA_AUTH}`, 'Content-Type': 'application/json', Accept: 'application/json', ...options.headers, }, }); } async function getJiraProjects(): Promise> { const res = await jiraFetch('/project'); if (!res.ok) return []; return res.json() as Promise>; } async function deleteProject(key: string): Promise { const res = await jiraFetch(`/project/${key}?enableUndo=false`, { method: 'DELETE' }); return res.status === 204; } async function createProject(key: string, name: string, leadAccountId: string): Promise { const res = await jiraFetch('/project', { method: 'POST', body: JSON.stringify({ key, name, projectTypeKey: 'business', leadAccountId, assigneeType: 'UNASSIGNED', }), }); if (res.ok || res.status === 201) { const data = await res.json() as { id: string }; return data.id; } console.error(` FAIL create ${key}: ${res.status} ${await res.text()}`); return null; } async function assignScheme(projectId: string): Promise { const res = await jiraFetch('/issuetypescheme/project', { method: 'PUT', body: JSON.stringify({ issueTypeSchemeId: SHARED_SCHEME_ID, projectId, }), }); return res.ok || res.status === 204; } async function verifyScheme(key: string): Promise { const res = await jiraFetch(`/project/${key}/statuses`); if (!res.ok) return false; const statuses = await res.json() as Array<{ name: string }>; const names = statuses.map(s => s.name); return names.includes('Epic') && names.includes('Task') && names.includes('Bug'); } async function main() { console.log('=== Prepare Projects for Migration ==='); console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); console.log(`Exclude: ${EXCLUDE.length > 0 ? EXCLUDE.join(', ') : 'none'}`); console.log(''); // Get current user for project lead const meRes = await jiraFetch('/myself'); const me = await meRes.json() as { accountId: string }; // Get existing Jira projects const jiraProjects = await getJiraProjects(); const jiraProjectMap = new Map(jiraProjects.map(p => [p.key, p])); console.log(`Jira projects: ${jiraProjects.length}`); // Get DB projects with tasks const dbProjects = await pool.query( `SELECT p.key, p.name, COUNT(t.id) as task_count, MAX(CAST(REGEXP_REPLACE(t.id, '^' || p.key || '-', '') AS INTEGER)) as max_id FROM projects p JOIN tasks t ON t.project = p.key WHERE p.key ~ '^[A-Z]{2,5}$' GROUP BY p.key, p.name ORDER BY p.key` ); console.log(`DB projects with tasks: ${dbProjects.rows.length}`); console.log(''); // Filter: must exist in Jira, not excluded const toProcess = dbProjects.rows.filter((p: any) => { if (EXCLUDE.includes(p.key)) return false; if (!jiraProjectMap.has(p.key)) return false; return true; }); console.log(`Projects to prepare: ${toProcess.length}`); console.log(''); // Summary table console.log('Project | Tasks | Max ID | Placeholders | Status'); console.log('--------|-------|--------|-------------|-------'); let totalTasks = 0; let totalPlaceholders = 0; for (const p of toProcess) { const placeholders = p.max_id - p.task_count; totalTasks += parseInt(p.task_count); totalPlaceholders += placeholders; console.log(`${p.key.padEnd(7)} | ${String(p.task_count).padStart(5)} | ${String(p.max_id).padStart(6)} | ${String(placeholders).padStart(11)} | pending`); } console.log(`TOTAL | ${String(totalTasks).padStart(5)} | ${String(totalTasks + totalPlaceholders).padStart(6)} | ${String(totalPlaceholders).padStart(11)} |`); console.log(''); if (DRY_RUN) { console.log('[DRY RUN] Would process above projects. Run without --dry-run to execute.'); await pool.end(); return; } // Process each project let success = 0; let failed = 0; for (let i = 0; i < toProcess.length; i++) { const p = toProcess[i]; const jiraProject = jiraProjectMap.get(p.key)!; console.log(`[${i + 1}/${toProcess.length}] ${p.key} (${p.name})...`); // 1. Delete await delay(1000); const deleted = await deleteProject(p.key); if (!deleted) { console.error(` FAIL delete ${p.key}`); failed++; continue; } console.log(` Deleted`); // 2. Wait a bit for Jira to process await delay(2000); // 3. Recreate const newId = await createProject(p.key, jiraProject.name || p.name, me.accountId); if (!newId) { console.error(` FAIL recreate ${p.key}`); failed++; continue; } console.log(` Recreated (id=${newId})`); // 4. Assign shared scheme await delay(1000); const schemeOk = await assignScheme(newId); if (!schemeOk) { console.error(` FAIL assign scheme for ${p.key}`); failed++; continue; } // 5. Verify const verified = await verifyScheme(p.key); if (!verified) { console.error(` FAIL verify scheme for ${p.key} (missing Epic/Task/Bug)`); failed++; continue; } console.log(` Scheme OK (Epic/Task/Bug)`); success++; } console.log(`\n=== Preparation Summary ===`); console.log(`Success: ${success}`); console.log(`Failed: ${failed}`); console.log(`\nRun migration: npx tsx scripts/migrate-tasks-to-jira.ts --skip-preflight`); await pool.end(); } main().catch(err => { console.error(err); process.exit(1); });