Skip to content

Commit f4b256c

Browse files
BjoernSchotteclaude
andcommitted
feat(jira): add import/export for issues with comments and attachments
Export features: - jira export --jql <query> --output <file> [--format csv|json] - Supports comments and attachments (--no-comments, --no-attachments) - JSON: base64-encoded attachments inline - CSV: attachments saved to separate directory Import features: - jira import --file <path> --project <key> [--dry-run] - Create-only mode (doesn't update existing issues) - Supports comments and attachments from export files - --skip-attachments to import without uploading files Client additions: - getIssueAttachments() - fetch attachment metadata - downloadAttachment() - download binary content - uploadAttachment() - multipart upload to issue Completes roadmap item #9 (Import/Export). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 43a0e99 commit f4b256c

7 files changed

Lines changed: 1014 additions & 13 deletions

File tree

apps/cli/src/commands/jira.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
TimerState,
2020
SprintMetrics,
2121
BulkOperationSummary,
22+
ExportData,
23+
ImportResult,
2224
parseTimeToSeconds,
2325
secondsToJiraFormat,
2426
secondsToHuman,
@@ -37,6 +39,10 @@ import {
3739
calculateBurndown,
3840
getStoryPoints,
3941
generateProgressBar as generateAnalyticsProgressBar,
42+
collectExportData,
43+
writeExportFile,
44+
parseImportFile,
45+
importIssues,
4046
} from "@atlcli/jira";
4147

4248
export async function handleJira(
@@ -73,6 +79,12 @@ export async function handleJira(
7379
case "filter":
7480
await handleFilter(rest, flags, opts);
7581
return;
82+
case "export":
83+
await handleExport(flags, opts);
84+
return;
85+
case "import":
86+
await handleImport(flags, opts);
87+
return;
7688
case "search":
7789
await handleSearch(rest, flags, opts);
7890
return;
@@ -3095,6 +3107,244 @@ function formatIssue(issue: JiraIssue): Record<string, unknown> {
30953107
};
30963108
}
30973109

3110+
// ============ Export/Import Commands ============
3111+
3112+
async function handleExport(
3113+
flags: Record<string, string | boolean>,
3114+
opts: OutputOptions
3115+
): Promise<void> {
3116+
const jql = getFlag(flags, "jql");
3117+
const outputPath = getFlag(flags, "o") || getFlag(flags, "output");
3118+
const format = (getFlag(flags, "format") || "json") as "csv" | "json";
3119+
const includeComments = !hasFlag(flags, "no-comments");
3120+
const includeAttachments = !hasFlag(flags, "no-attachments");
3121+
3122+
if (!jql) {
3123+
output(exportHelp(), opts);
3124+
return;
3125+
}
3126+
3127+
if (!outputPath) {
3128+
fail(opts, 1, ERROR_CODES.USAGE, "--output (-o) is required for export.");
3129+
return;
3130+
}
3131+
3132+
const client = await getClient(flags, opts);
3133+
3134+
// Fetch all matching issues
3135+
const allIssues: JiraIssue[] = [];
3136+
let startAt = 0;
3137+
const maxResults = 100;
3138+
3139+
if (!opts.json) {
3140+
process.stderr.write("Searching issues...\n");
3141+
}
3142+
3143+
while (true) {
3144+
const result = await client.search(jql, {
3145+
startAt,
3146+
maxResults,
3147+
fields: ["*all"],
3148+
});
3149+
allIssues.push(...result.issues);
3150+
if (allIssues.length >= result.total || result.issues.length === 0) break;
3151+
startAt += maxResults;
3152+
}
3153+
3154+
if (allIssues.length === 0) {
3155+
if (opts.json) {
3156+
output({ schemaVersion: "1", exported: 0, message: "No issues found" }, opts);
3157+
} else {
3158+
output("No issues found matching the query.", opts);
3159+
}
3160+
return;
3161+
}
3162+
3163+
if (!opts.json) {
3164+
process.stderr.write(`Found ${allIssues.length} issues. Collecting data...\n`);
3165+
}
3166+
3167+
// Collect export data
3168+
const exportedIssues = await collectExportData(
3169+
client,
3170+
allIssues,
3171+
{
3172+
format,
3173+
includeComments,
3174+
includeAttachments,
3175+
outputPath,
3176+
},
3177+
(current, total, key) => {
3178+
if (!opts.json) {
3179+
process.stderr.write(`\rProcessing ${current}/${total}: ${key}...`);
3180+
}
3181+
}
3182+
);
3183+
3184+
if (!opts.json) {
3185+
process.stderr.write("\n");
3186+
}
3187+
3188+
// Build export data structure
3189+
const exportData: ExportData = {
3190+
exportedAt: new Date().toISOString(),
3191+
query: jql,
3192+
issues: exportedIssues,
3193+
};
3194+
3195+
// Write to file
3196+
await writeExportFile(exportData, {
3197+
format,
3198+
includeComments,
3199+
includeAttachments,
3200+
outputPath,
3201+
});
3202+
3203+
if (opts.json) {
3204+
output({
3205+
schemaVersion: "1",
3206+
exported: exportedIssues.length,
3207+
format,
3208+
outputPath,
3209+
includeComments,
3210+
includeAttachments,
3211+
}, opts);
3212+
} else {
3213+
output(`Exported ${exportedIssues.length} issues to ${outputPath}`, opts);
3214+
if (format === "csv" && includeAttachments) {
3215+
output(`Attachments saved to: ${outputPath}_attachments/`, opts);
3216+
}
3217+
}
3218+
}
3219+
3220+
async function handleImport(
3221+
flags: Record<string, string | boolean>,
3222+
opts: OutputOptions
3223+
): Promise<void> {
3224+
const filePath = getFlag(flags, "file");
3225+
const project = getFlag(flags, "project");
3226+
const dryRun = hasFlag(flags, "dry-run");
3227+
const skipAttachments = hasFlag(flags, "skip-attachments");
3228+
3229+
if (!filePath || !project) {
3230+
output(importHelp(), opts);
3231+
return;
3232+
}
3233+
3234+
// Parse the import file
3235+
let issues;
3236+
try {
3237+
issues = await parseImportFile(filePath);
3238+
} catch (err) {
3239+
fail(opts, 1, ERROR_CODES.USAGE, `Failed to parse import file: ${err instanceof Error ? err.message : String(err)}`);
3240+
return;
3241+
}
3242+
3243+
if (issues.length === 0) {
3244+
if (opts.json) {
3245+
output({ schemaVersion: "1", imported: 0, message: "No issues found in file" }, opts);
3246+
} else {
3247+
output("No issues found in the import file.", opts);
3248+
}
3249+
return;
3250+
}
3251+
3252+
if (!opts.json) {
3253+
if (dryRun) {
3254+
process.stderr.write(`[DRY RUN] Would import ${issues.length} issues into project ${project}\n`);
3255+
} else {
3256+
process.stderr.write(`Importing ${issues.length} issues into project ${project}...\n`);
3257+
}
3258+
}
3259+
3260+
const client = await getClient(flags, opts);
3261+
3262+
const result = await importIssues(
3263+
client,
3264+
issues,
3265+
{ project, dryRun, skipAttachments },
3266+
(current, total, summary, status) => {
3267+
if (!opts.json) {
3268+
process.stderr.write(`\r${dryRun ? "[DRY RUN] " : ""}${current}/${total}: ${summary.substring(0, 40)}...`);
3269+
}
3270+
}
3271+
);
3272+
3273+
if (!opts.json) {
3274+
process.stderr.write("\n");
3275+
}
3276+
3277+
if (opts.json) {
3278+
output({
3279+
schemaVersion: "1",
3280+
dryRun,
3281+
total: result.total,
3282+
created: result.created,
3283+
skipped: result.skipped,
3284+
failed: result.failed,
3285+
issues: result.issues,
3286+
}, opts);
3287+
} else {
3288+
output(`${dryRun ? "[DRY RUN] " : ""}Import complete:`, opts);
3289+
output(` Total: ${result.total}`, opts);
3290+
output(` Created: ${result.created}`, opts);
3291+
if (result.skipped > 0) output(` Skipped: ${result.skipped}`, opts);
3292+
if (result.failed > 0) {
3293+
output(` Failed: ${result.failed}`, opts);
3294+
for (const issue of result.issues.filter((i) => i.status === "failed")) {
3295+
output(` - ${issue.summary}: ${issue.error}`, opts);
3296+
}
3297+
}
3298+
}
3299+
}
3300+
3301+
function exportHelp(): string {
3302+
return `atlcli jira export --jql <query> -o <file> [options]
3303+
3304+
Export issues to CSV or JSON with comments and attachments.
3305+
3306+
Options:
3307+
--jql <query> JQL query to select issues (required)
3308+
-o, --output <file> Output file path (required)
3309+
--format <format> Output format: json (default) or csv
3310+
--no-comments Exclude comments from export
3311+
--no-attachments Exclude attachments from export
3312+
--profile <name> Use a specific auth profile
3313+
--json JSON output for status
3314+
3315+
Examples:
3316+
jira export --jql "project = PROJ" -o issues.json
3317+
jira export --jql "assignee = currentUser()" -o my-issues.csv --format csv
3318+
jira export --jql "sprint in openSprints()" -o sprint.json --no-attachments
3319+
`;
3320+
}
3321+
3322+
function importHelp(): string {
3323+
return `atlcli jira import --file <path> --project <key> [options]
3324+
3325+
Import issues from CSV or JSON file (create-only mode).
3326+
3327+
Options:
3328+
--file <path> Import file path (required)
3329+
--project <key> Target project key (required)
3330+
--dry-run Preview import without creating issues
3331+
--skip-attachments Skip attachment uploads
3332+
--profile <name> Use a specific auth profile
3333+
--json JSON output
3334+
3335+
Notes:
3336+
- Import creates new issues only (does not update existing)
3337+
- Issues with existing keys are skipped
3338+
- Required fields: summary, issuetype
3339+
- Comments and attachments are included if present in file
3340+
3341+
Examples:
3342+
jira import --file issues.json --project PROJ --dry-run
3343+
jira import --file backup.csv --project PERSONAL
3344+
jira import --file export.json --project PROJ --skip-attachments
3345+
`;
3346+
}
3347+
30983348
function jiraHelp(): string {
30993349
return `atlcli jira <command>
31003350
@@ -3108,6 +3358,8 @@ Commands:
31083358
analyze Sprint analytics (velocity, burndown, scope-change, predictability)
31093359
bulk Bulk operations (edit, transition, label, delete)
31103360
filter Saved JQL filters (list, get, create, update, delete, share)
3361+
export Export issues to CSV/JSON with comments and attachments
3362+
import Import issues from CSV/JSON file
31113363
search Search with JQL
31123364
me Get current user info
31133365
@@ -3121,6 +3373,8 @@ Examples:
31213373
atlcli jira filter create --name "My Issues" --jql "assignee = currentUser()"
31223374
atlcli jira bulk label add sprint-47 --jql "sprint in openSprints()"
31233375
atlcli jira analyze velocity --board 123 --sprints 5
3376+
atlcli jira export --jql "project = PROJ" -o issues.json
3377+
atlcli jira import --file issues.json --project PROJ --dry-run
31243378
atlcli jira search --project PROJ --assignee me
31253379
`;
31263380
}

0 commit comments

Comments
 (0)