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>
This commit is contained in:
221
scripts/prepare-all-projects.ts
Normal file
221
scripts/prepare-all-projects.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function jiraFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
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<Array<{ key: string; name: string; id: string }>> {
|
||||
const res = await jiraFetch('/project');
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<Array<{ key: string; name: string; id: string }>>;
|
||||
}
|
||||
|
||||
async function deleteProject(key: string): Promise<boolean> {
|
||||
const res = await jiraFetch(`/project/${key}?enableUndo=false`, { method: 'DELETE' });
|
||||
return res.status === 204;
|
||||
}
|
||||
|
||||
async function createProject(key: string, name: string, leadAccountId: string): Promise<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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); });
|
||||
Reference in New Issue
Block a user