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:
Christian Gick
2026-02-07 12:33:49 +02:00
parent bd5d95beff
commit 1227e5b339
24 changed files with 2177 additions and 2256 deletions

213
scripts/jira-admin.ts Normal file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env npx tsx
/**
* Jira admin helper for migration (CF-762)
* Usage:
* npx tsx scripts/jira-admin.ts get-project CF
* npx tsx scripts/jira-admin.ts delete-project CF
* npx tsx scripts/jira-admin.ts create-project CF "Claude Framework"
* npx tsx scripts/jira-admin.ts count-issues CF
* npx tsx scripts/jira-admin.ts delete-all-issues CF
*/
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 || process.env.JIRA_EMAIL || '';
const JIRA_TOKEN = process.env.JIRA_API_TOKEN || '';
const JIRA_AUTH = Buffer.from(`${JIRA_USER}:${JIRA_TOKEN}`).toString('base64');
async function jiraFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = path.startsWith('http') ? path : `${JIRA_URL}/rest/api/3${path}`;
return fetch(url, {
...options,
headers: {
'Authorization': `Basic ${JIRA_AUTH}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
});
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
const [command, ...cmdArgs] = process.argv.slice(2);
async function main() {
switch (command) {
case 'get-project': {
const key = cmdArgs[0];
const res = await jiraFetch(`/project/${key}`);
if (!res.ok) {
console.error(`Failed: ${res.status} ${await res.text()}`);
return;
}
const data = await res.json() as Record<string, unknown>;
console.log(JSON.stringify(data, null, 2));
break;
}
case 'list-projects': {
const res = await jiraFetch('/project');
if (!res.ok) {
console.error(`Failed: ${res.status} ${await res.text()}`);
return;
}
const projects = await res.json() as Array<{ key: string; name: string; id: string; projectTypeKey: string }>;
console.log(`Total: ${projects.length} projects`);
for (const p of projects) {
console.log(` ${p.key}: ${p.name} (id=${p.id}, type=${p.projectTypeKey})`);
}
break;
}
case 'count-issues': {
const key = cmdArgs[0];
const res = await jiraFetch(`/search/jql`, {
method: 'POST',
body: JSON.stringify({ jql: `project="${key}"`, maxResults: 1 }),
});
if (!res.ok) {
console.error(`Failed: ${res.status} ${await res.text()}`);
return;
}
const data = await res.json() as { total: number };
console.log(`${key}: ${data.total} issues`);
break;
}
case 'list-issues': {
const key = cmdArgs[0];
const max = parseInt(cmdArgs[1] || '20');
const res = await jiraFetch(`/search/jql`, {
method: 'POST',
body: JSON.stringify({ jql: `project="${key}" ORDER BY key ASC`, maxResults: max, fields: ['key', 'summary', 'issuetype', 'status'] }),
});
if (!res.ok) {
console.error(`Failed: ${res.status} ${await res.text()}`);
return;
}
const data = await res.json() as { total: number; issues: Array<{ key: string; fields: { summary: string; issuetype: { name: string }; status: { name: string } } }> };
console.log(`${key}: ${data.total} total issues (showing ${data.issues.length})`);
for (const i of data.issues) {
console.log(` ${i.key} [${i.fields.issuetype.name}] ${i.fields.status.name}: ${i.fields.summary.substring(0, 60)}`);
}
break;
}
case 'delete-all-issues': {
const key = cmdArgs[0];
if (!key) { console.error('Usage: delete-all-issues <PROJECT_KEY>'); return; }
// Get all issues
let startAt = 0;
const allKeys: string[] = [];
while (true) {
const res = await jiraFetch(`/search/jql`, {
method: 'POST',
body: JSON.stringify({ jql: `project="${key}" ORDER BY key ASC`, maxResults: 100, startAt, fields: ['key'] }),
});
if (!res.ok) { console.error(`Failed: ${res.status} ${await res.text()}`); return; }
const data = await res.json() as { total: number; issues: Array<{ key: string }> };
if (data.issues.length === 0) break;
allKeys.push(...data.issues.map(i => i.key));
startAt += data.issues.length;
if (startAt >= data.total) break;
}
console.log(`Found ${allKeys.length} issues to delete in ${key}`);
for (let i = 0; i < allKeys.length; i++) {
await delay(300);
const res = await jiraFetch(`/issue/${allKeys[i]}`, { method: 'DELETE' });
if (!res.ok) {
console.error(` FAIL delete ${allKeys[i]}: ${res.status}`);
}
if (i % 10 === 0) console.log(` [${i + 1}/${allKeys.length}] Deleted ${allKeys[i]}`);
}
console.log(`Deleted ${allKeys.length} issues from ${key}`);
break;
}
case 'delete-project': {
const key = cmdArgs[0];
if (!key) { console.error('Usage: delete-project <PROJECT_KEY>'); return; }
// enableUndo=false for permanent deletion
const res = await jiraFetch(`/project/${key}?enableUndo=false`, { method: 'DELETE' });
if (res.status === 204) {
console.log(`Project ${key} deleted permanently`);
} else {
console.error(`Failed: ${res.status} ${await res.text()}`);
}
break;
}
case 'create-project': {
const key = cmdArgs[0];
const name = cmdArgs[1] || key;
if (!key) { console.error('Usage: create-project <KEY> <NAME>'); return; }
// Get current user account ID for lead
const meRes = await jiraFetch('/myself');
const me = await meRes.json() as { accountId: string };
const body = {
key,
name,
projectTypeKey: 'business',
leadAccountId: me.accountId,
assigneeType: 'UNASSIGNED',
};
const res = await jiraFetch('/project', {
method: 'POST',
body: JSON.stringify(body),
});
if (res.ok || res.status === 201) {
const data = await res.json() as { id: string; key: string };
console.log(`Project created: ${data.key} (id=${data.id})`);
} else {
console.error(`Failed: ${res.status} ${await res.text()}`);
}
break;
}
case 'get-schemes': {
const key = cmdArgs[0];
// Get issue type scheme for project
const res = await jiraFetch(`/project/${key}`);
if (!res.ok) {
console.error(`Failed: ${res.status} ${await res.text()}`);
return;
}
const data = await res.json() as Record<string, unknown>;
console.log('Project type:', (data as any).projectTypeKey);
console.log('Style:', (data as any).style);
// Get issue types
const itRes = await jiraFetch(`/project/${key}/statuses`);
if (itRes.ok) {
const itData = await itRes.json() as Array<{ name: string; id: string; statuses: Array<{ name: string }> }>;
console.log('\nIssue types and statuses:');
for (const it of itData) {
console.log(` ${it.name} (id=${it.id}): ${it.statuses.map(s => s.name).join(', ')}`);
}
}
break;
}
default:
console.log('Commands: list-projects, get-project, count-issues, list-issues, delete-all-issues, delete-project, create-project, get-schemes');
}
}
main().catch(err => { console.error(err); process.exit(1); });