# 2ticketss
2 complementary command-line tools for GitHub issue management - **00-jira-to-gh-issues**: A Rust tool that converts Jira CSV exports to GitHub issue markdown files compatible with gh-issue-generator. It handles messy CSV data and preserves issue metadata - **01-gh-issue-generator**: A Rust tool that creates GitHub issues from Markdown files with YAML front matter. It parses structured Markdown, supports batch processing, and integrates with GitHub CLI
This commit is contained in:
17
00-jira-to-gh-issues/Cargo.toml
Normal file
17
00-jira-to-gh-issues/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "jira-to-gh-issues"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A tool to convert Jira CSV exports to GitHub issue markdown format"
|
||||
authors = ["b3"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
csv = "1.2"
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
232
00-jira-to-gh-issues/README.md
Normal file
232
00-jira-to-gh-issues/README.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Jira to GitHub Issues Converter
|
||||
|
||||
A Rust command-line tool that converts Jira CSV exports to GitHub issue markdown files compatible with the [gh-issue-generator](../01-gh-issue-generator) tool.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Rust](https://www.rust-lang.org/tools/install) installed (cargo, rustc)
|
||||
- Basic familiarity with Jira CSV exports
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Build from source
|
||||
|
||||
1. Clone this repository
|
||||
```bash
|
||||
git clone https://github.com/bee8333/2ticketss.git
|
||||
cd 2ticketss/00-jira-to-gh-issues
|
||||
```
|
||||
|
||||
2. Build the application:
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
3. The executable will be in `target/release/jira-to-gh-issues`
|
||||
|
||||
4. Optional: Add to your PATH
|
||||
```bash
|
||||
# Linux/macOS
|
||||
cp target/release/jira-to-gh-issues ~/.local/bin/
|
||||
|
||||
# or add the following to your .bashrc or .zshrc
|
||||
export PATH="$PATH:/path/to/2ticketss/00-jira-to-gh-issues/target/release"
|
||||
```
|
||||
|
||||
### Option 2: Install with Cargo
|
||||
|
||||
If you have Rust installed, you can install directly with:
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## Exporting Issues from Jira
|
||||
|
||||
1. In Jira, go to the issue list view (with all filters applied)
|
||||
2. Click "Export" and select "CSV (All fields)"
|
||||
3. Save the CSV file
|
||||
|
||||
Required Jira fields:
|
||||
- Summary (required)
|
||||
- Issue key (required)
|
||||
|
||||
Recommended fields (for best results):
|
||||
- Issue Type
|
||||
- Priority
|
||||
- Description
|
||||
- Assignee
|
||||
- Reporter
|
||||
- Created
|
||||
- Labels
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
jira-to-gh-issues [OPTIONS] --input <jira_export.csv>
|
||||
```
|
||||
|
||||
Arguments:
|
||||
- `--input, -i <jira_export.csv>`: Path to the CSV file exported from Jira (required)
|
||||
|
||||
Options:
|
||||
- `--output, -o <output_dir>`: Directory where the markdown files will be saved
|
||||
- `--status, -s <status>`: Default status for the issues (draft or ready)
|
||||
- `--verbose, -v`: Increase verbosity level (can be used multiple times: -v, -vv)
|
||||
- `--config, -c <config>`: Path to config file
|
||||
|
||||
### Logging Options
|
||||
|
||||
You can control log verbosity in two ways:
|
||||
|
||||
1. Using the `--verbose` flag:
|
||||
- `-v`: Debug level logging
|
||||
- `-vv`: Trace level logging
|
||||
|
||||
2. Using the `RUST_LOG` environment variable:
|
||||
```bash
|
||||
# Set global log level
|
||||
RUST_LOG=debug jira-to-gh-issues --input issues.csv
|
||||
|
||||
# Set per-module log level
|
||||
RUST_LOG=jira_to_gh_issues=trace,csv=warn jira-to-gh-issues --input issues.csv
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The tool supports configuration files to store default settings. Configuration is searched in these locations (in order):
|
||||
|
||||
1. Path specified with `--config` option
|
||||
2. `.jira-to-gh-issues.toml` in current directory
|
||||
3. `~/.jira-to-gh-issues.toml` in home directory
|
||||
4. `~/.config/jira-to-gh-issues/config.toml` in XDG config directory
|
||||
|
||||
A default configuration file will be created at `~/.config/jira-to-gh-issues/config.toml` if none exists.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```toml
|
||||
# Default output directory
|
||||
output_dir = "00-jira-to-gh-issues/output"
|
||||
|
||||
# Default issue status (draft or ready)
|
||||
default_status = "ready"
|
||||
|
||||
# Required fields in CSV
|
||||
required_fields = ["Summary", "Issue key"]
|
||||
```
|
||||
|
||||
### Creating or Editing Configuration
|
||||
|
||||
You can manually create or edit the configuration file, or run the tool once to create a default configuration automatically.
|
||||
|
||||
For example, to set custom defaults:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/jira-to-gh-issues
|
||||
cat > ~/.config/jira-to-gh-issues/config.toml << EOF
|
||||
output_dir = "github-issues/jira-exports"
|
||||
default_status = "draft"
|
||||
required_fields = ["Summary", "Issue key", "Description"]
|
||||
EOF
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### 1. Export issues from Jira
|
||||
```bash
|
||||
# Assuming you've exported Jira issues to "sprint-backlog.csv"
|
||||
```
|
||||
|
||||
### 2. Convert to GitHub issue format
|
||||
```bash
|
||||
jira-to-gh-issues --input sprint-backlog.csv --output github-issues
|
||||
```
|
||||
|
||||
### 3. Create GitHub issues using gh-issue-generator
|
||||
```bash
|
||||
cd ..
|
||||
gh-issue-generator --repo myuser/myrepo github-issues/20240405123045/batch.md
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The tool reads a CSV file exported from Jira
|
||||
2. For each issue in the CSV, it:
|
||||
- Extracts relevant information (title, description, labels, etc.)
|
||||
- Converts it to the GitHub issue markdown format with YAML front matter
|
||||
- Writes individual markdown files for each issue
|
||||
- Creates a batch file containing all issues
|
||||
3. Output is saved in a timestamped directory to avoid overwriting previous conversions
|
||||
4. An error log is generated for any issues that couldn't be processed
|
||||
|
||||
## Output Format
|
||||
|
||||
The tool generates:
|
||||
1. Individual markdown files for each issue (named after the Jira issue key)
|
||||
2. A batch file containing all issues in the format expected by gh-issue-generator
|
||||
3. An error log file listing any processing issues
|
||||
|
||||
Each generated file follows the format required by the gh-issue-generator tool:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Issue title
|
||||
status: ready (or draft)
|
||||
labels:
|
||||
- label1
|
||||
- label2
|
||||
assignees:
|
||||
- assignee1
|
||||
---
|
||||
|
||||
# Issue title
|
||||
|
||||
## Description
|
||||
|
||||
Issue description...
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Jira Issue**: JIRA-123
|
||||
- **Created**: Creation date
|
||||
- **Reporter**: Reporter name
|
||||
```
|
||||
|
||||
## Mapping Rules
|
||||
|
||||
| Jira Field | GitHub Issue Field |
|
||||
|---------------|---------------------------|
|
||||
| Summary | title |
|
||||
| Issue Type | Converted to a label |
|
||||
| Priority | High/Critical → priority label |
|
||||
| Labels | labels |
|
||||
| Description | Description section |
|
||||
| Assignee | assignees |
|
||||
| Reporter | Listed in metadata |
|
||||
| Created | Listed in metadata |
|
||||
| Issue Key | Listed in metadata & filename |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CSV Import Issues
|
||||
- Ensure the CSV export from Jira includes the required fields (Summary, Issue key)
|
||||
- If the import fails with "Missing required columns", verify your Jira export contains these field names
|
||||
- For malformed CSV files, try opening in a spreadsheet application and re-saving
|
||||
|
||||
### Character Encoding
|
||||
- If you see corrupted characters, ensure your CSV is UTF-8 encoded
|
||||
- Some Jira instances export in different encodings depending on region settings
|
||||
|
||||
### Large Exports
|
||||
- For very large exports (1000+ issues), consider splitting into multiple files
|
||||
- Processing large files may require more memory
|
||||
|
||||
## Limitations
|
||||
|
||||
- Complex Jira markup may not convert perfectly to Markdown
|
||||
- Attachments from Jira issues are not transferred
|
||||
- Comments are not included in the conversion
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
548
00-jira-to-gh-issues/src/main.rs
Normal file
548
00-jira-to-gh-issues/src/main.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Write, Read};
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Local;
|
||||
use clap::Parser;
|
||||
use csv::ReaderBuilder;
|
||||
use dirs::home_dir;
|
||||
use log::{info, warn, error, debug, trace, LevelFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Jira CSV to GitHub Issues Converter
|
||||
///
|
||||
/// This tool converts Jira CSV exports to GitHub issue markdown files
|
||||
/// that are compatible with the gh-issue-generator tool. It handles field
|
||||
/// mapping, formatting conversions, and generates both individual markdown
|
||||
/// files and a batch file for bulk creation of GitHub issues.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
author,
|
||||
version,
|
||||
about,
|
||||
long_about = "A command-line utility that converts Jira CSV exports to GitHub issue markdown files.
|
||||
It extracts key fields from the Jira export, including summary, description, issue type, priority,
|
||||
labels, assignees, and other metadata, then converts them to GitHub-compatible format.
|
||||
|
||||
The tool creates a timestamped output directory containing:
|
||||
- Individual markdown files for each issue
|
||||
- A batch file containing all issues
|
||||
- An error log tracking any issues encountered during processing
|
||||
|
||||
For best results, ensure your Jira export includes at least the Summary and Issue key fields."
|
||||
)]
|
||||
struct Args {
|
||||
/// Input CSV file exported from Jira
|
||||
///
|
||||
/// Path to the CSV file exported from Jira containing issues to convert.
|
||||
/// Export this file from Jira by going to the issue list view,
|
||||
/// clicking 'Export' and selecting 'CSV (All fields)'.
|
||||
#[arg(short, long, required = true)]
|
||||
input: PathBuf,
|
||||
|
||||
/// Output directory for generated markdown files
|
||||
///
|
||||
/// Directory where the markdown files will be saved. A timestamped
|
||||
/// subdirectory will be created within this path to avoid overwriting
|
||||
/// previous conversions.
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Default status to use (draft or ready)
|
||||
///
|
||||
/// Determines whether created issues are marked as draft or ready.
|
||||
/// Draft issues will not be created when using gh-issue-generator.
|
||||
#[arg(short, long)]
|
||||
status: Option<String>,
|
||||
|
||||
/// Verbosity level (can be used multiple times)
|
||||
///
|
||||
/// Controls the amount of information displayed during execution:
|
||||
/// -v: Debug level (detailed information for troubleshooting)
|
||||
/// -vv: Trace level (very verbose, all operations logged)
|
||||
///
|
||||
/// You can also use the RUST_LOG environment variable instead.
|
||||
#[arg(short, long, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
/// Path to config file
|
||||
///
|
||||
/// Path to a TOML configuration file. If not specified, the tool will
|
||||
/// search for configuration in standard locations:
|
||||
/// - .jira-to-gh-issues.toml in current directory
|
||||
/// - ~/.jira-to-gh-issues.toml in home directory
|
||||
/// - ~/.config/jira-to-gh-issues/config.toml
|
||||
#[arg(short = 'c', long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
/// Default output directory
|
||||
output_dir: Option<String>,
|
||||
|
||||
/// Default issue status (draft or ready)
|
||||
default_status: Option<String>,
|
||||
|
||||
/// Required fields in CSV (comma-separated list)
|
||||
#[serde(default = "default_required_fields")]
|
||||
required_fields: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_required_fields() -> Vec<String> {
|
||||
vec!["Summary".to_string(), "Issue key".to_string()]
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_dir: Some("00-jira-to-gh-issues/output".to_string()),
|
||||
default_status: Some("ready".to_string()),
|
||||
required_fields: default_required_fields(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from file or create default if not exists
|
||||
fn load(config_path: Option<&PathBuf>) -> Result<Self> {
|
||||
// If path is provided, try to load from there
|
||||
if let Some(path) = config_path {
|
||||
debug!("Loading config from specified path: {}", path.display());
|
||||
return Self::load_from_file(path);
|
||||
}
|
||||
|
||||
// Try to load from default locations
|
||||
let config_paths = [
|
||||
// Current directory
|
||||
PathBuf::from(".jira-to-gh-issues.toml"),
|
||||
// Home directory
|
||||
home_dir().map(|h| h.join(".jira-to-gh-issues.toml")).unwrap_or_default(),
|
||||
// XDG config directory
|
||||
home_dir().map(|h| h.join(".config/jira-to-gh-issues/config.toml")).unwrap_or_default(),
|
||||
];
|
||||
|
||||
for path in &config_paths {
|
||||
if path.exists() {
|
||||
debug!("Found config file at: {}", path.display());
|
||||
return Self::load_from_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
// No config file found, use defaults
|
||||
debug!("No config file found, using defaults");
|
||||
Ok(Self::default())
|
||||
}
|
||||
|
||||
/// Load configuration from a specific file
|
||||
fn load_from_file(path: &PathBuf) -> Result<Self> {
|
||||
let mut file = File::open(path)
|
||||
.context(format!("Failed to open config file: {}", path.display()))?;
|
||||
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.context("Failed to read config file")?;
|
||||
|
||||
toml::from_str(&content)
|
||||
.context("Failed to parse config file as TOML")
|
||||
}
|
||||
|
||||
/// Save configuration to file
|
||||
fn save(&self, path: &PathBuf) -> Result<()> {
|
||||
debug!("Saving config to: {}", path.display());
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = toml::to_string_pretty(self)
|
||||
.context("Failed to serialize config to TOML")?;
|
||||
|
||||
let mut file = File::create(path)
|
||||
.context(format!("Failed to create config file: {}", path.display()))?;
|
||||
|
||||
file.write_all(content.as_bytes())
|
||||
.context("Failed to write config file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a default config file if it doesn't exist
|
||||
fn create_default_if_not_exists() -> Result<()> {
|
||||
let default_path = home_dir()
|
||||
.map(|h| h.join(".config/jira-to-gh-issues/config.toml"))
|
||||
.ok_or_else(|| anyhow!("Failed to determine home directory"))?;
|
||||
|
||||
if !default_path.exists() {
|
||||
debug!("Creating default config file at: {}", default_path.display());
|
||||
let default_config = Self::default();
|
||||
default_config.save(&default_path)?;
|
||||
info!("Created default config file at: {}", default_path.display());
|
||||
println!("Created default config file at: {}", default_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessingStats {
|
||||
total_rows: usize,
|
||||
successful_conversions: usize,
|
||||
empty_rows: usize,
|
||||
parsing_errors: usize,
|
||||
required_field_missing: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ProcessingStats {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_rows: 0,
|
||||
successful_conversions: 0,
|
||||
empty_rows: 0,
|
||||
parsing_errors: 0,
|
||||
required_field_missing: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_summary(&self) {
|
||||
println!("\nProcessing Statistics:");
|
||||
println!(" Total rows in CSV: {}", self.total_rows);
|
||||
println!(" Successfully converted: {}", self.successful_conversions);
|
||||
println!(" Empty rows skipped: {}", self.empty_rows);
|
||||
println!(" Rows with parsing errors: {}", self.parsing_errors);
|
||||
|
||||
if !self.required_field_missing.is_empty() {
|
||||
println!("\n Required fields missing:");
|
||||
for (field, count) in &self.required_field_missing {
|
||||
println!(" {}: {} occurrences", field, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize logger with appropriate level
|
||||
let log_level = match args.verbose {
|
||||
0 => LevelFilter::Info,
|
||||
1 => LevelFilter::Debug,
|
||||
_ => LevelFilter::Trace,
|
||||
};
|
||||
|
||||
// Initialize the logger, respecting the RUST_LOG env var if set
|
||||
let mut builder = env_logger::Builder::new();
|
||||
|
||||
// If RUST_LOG is set, use that config; otherwise use our default based on -v flags
|
||||
match std::env::var("RUST_LOG") {
|
||||
Ok(_) => {
|
||||
// RUST_LOG is set, initialize with default env_logger behavior
|
||||
builder.init();
|
||||
},
|
||||
Err(_) => {
|
||||
// RUST_LOG not set, use our verbosity flag
|
||||
builder
|
||||
.filter_level(log_level)
|
||||
.format_timestamp(Some(env_logger::fmt::TimestampPrecision::Seconds))
|
||||
.init();
|
||||
|
||||
// Log a hint about RUST_LOG for users who use -v
|
||||
if args.verbose > 0 {
|
||||
debug!("Tip: You can also control log level with the RUST_LOG environment variable");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("Starting Jira to GitHub Issues Converter");
|
||||
|
||||
// Load configuration
|
||||
let config = Config::load(args.config.as_ref())?;
|
||||
debug!("Loaded configuration: {:?}", config);
|
||||
|
||||
// Create default config if it doesn't exist (only in normal operation)
|
||||
if args.verbose == 0 {
|
||||
if let Err(e) = Config::create_default_if_not_exists() {
|
||||
warn!("Failed to create default config file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output directory (command line takes precedence over config)
|
||||
let output_dir = match (&args.output, &config.output_dir) {
|
||||
(Some(path), _) => path.clone(),
|
||||
(None, Some(dir)) => {
|
||||
info!("Using output directory from config: {}", dir);
|
||||
PathBuf::from(dir)
|
||||
},
|
||||
(None, None) => {
|
||||
let default_path = PathBuf::from("00-jira-to-gh-issues/output");
|
||||
info!("Using default output directory: {}", default_path.display());
|
||||
default_path
|
||||
}
|
||||
};
|
||||
|
||||
// Determine issue status (command line takes precedence over config)
|
||||
let status = match (&args.status, &config.default_status) {
|
||||
(Some(s), _) => s.clone(),
|
||||
(None, Some(s)) => {
|
||||
info!("Using default status from config: {}", s);
|
||||
s.clone()
|
||||
},
|
||||
(None, None) => {
|
||||
let default_status = "ready".to_string();
|
||||
info!("Using default status: {}", default_status);
|
||||
default_status
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Input file: {}", args.input.display());
|
||||
debug!("Output directory: {}", output_dir.display());
|
||||
debug!("Default status: {}", status);
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// Read CSV file
|
||||
let file = File::open(&args.input)
|
||||
.with_context(|| format!("Failed to open input file: {}", args.input.display()))?;
|
||||
|
||||
let mut rdr = ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.flexible(true) // Allow for variable number of fields
|
||||
.from_reader(file);
|
||||
|
||||
// Read headers to identify field positions
|
||||
let headers = rdr.headers()?.clone();
|
||||
info!("Found {} columns in CSV", headers.len());
|
||||
|
||||
// Check for required columns
|
||||
let required_fields: Vec<&str> = config.required_fields.iter().map(|s| s.as_str()).collect();
|
||||
validate_headers(&headers, &required_fields)?;
|
||||
|
||||
// Prepare timestamp directory for output
|
||||
let timestamp = Local::now().format("%Y%m%d%H%M%S").to_string();
|
||||
let batch_dir = output_dir.join(timestamp);
|
||||
fs::create_dir_all(&batch_dir)?;
|
||||
info!("Created output directory: {}", batch_dir.display());
|
||||
|
||||
// Create batch file for all issues
|
||||
let mut batch_file = File::create(batch_dir.join("batch.md"))?;
|
||||
writeln!(batch_file, "# Jira Issues Batch\n")?;
|
||||
writeln!(batch_file, "Generated from Jira export on {}\n", Local::now().format("%Y-%m-%d %H:%M:%S"))?;
|
||||
|
||||
// Process each row in the CSV
|
||||
let mut stats = ProcessingStats::new();
|
||||
let mut error_log = File::create(batch_dir.join("error_log.txt"))?;
|
||||
|
||||
for (row_idx, result) in rdr.records().enumerate() {
|
||||
stats.total_rows += 1;
|
||||
|
||||
let record = match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
stats.parsing_errors += 1;
|
||||
let error_msg = format!("Error reading row {}: {}\n", row_idx + 1, e);
|
||||
error!("{}", error_msg.trim());
|
||||
writeln!(error_log, "{}", error_msg)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract fields safely with fallback to empty string
|
||||
let summary = get_field_by_name(&record, &headers, "Summary");
|
||||
let issue_key = get_field_by_name(&record, &headers, "Issue key");
|
||||
let issue_type = get_field_by_name(&record, &headers, "Issue Type");
|
||||
let priority = get_field_by_name(&record, &headers, "Priority");
|
||||
let description = get_field_by_name(&record, &headers, "Description");
|
||||
let assignee = get_field_by_name(&record, &headers, "Assignee");
|
||||
let reporter = get_field_by_name(&record, &headers, "Reporter");
|
||||
let created = get_field_by_name(&record, &headers, "Created");
|
||||
|
||||
// Validate required fields
|
||||
let mut missing_fields = Vec::new();
|
||||
if summary.is_empty() { missing_fields.push("Summary"); }
|
||||
if issue_key.is_empty() { missing_fields.push("Issue key"); }
|
||||
|
||||
if !missing_fields.is_empty() {
|
||||
stats.empty_rows += 1;
|
||||
for field in &missing_fields {
|
||||
*stats.required_field_missing.entry(field.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let error_msg = format!("Row {}: Missing required fields: {}\n",
|
||||
row_idx + 1, missing_fields.join(", "));
|
||||
warn!("{}", error_msg.trim());
|
||||
writeln!(error_log, "{}", error_msg)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
trace!("Processing row {}: Issue Key = {}, Summary = {}", row_idx + 1, issue_key, summary);
|
||||
|
||||
// Collect all labels from any "Labels" columns
|
||||
let mut labels = Vec::new();
|
||||
for (i, header) in headers.iter().enumerate() {
|
||||
if header == "Labels" && i < record.len() {
|
||||
let label = record.get(i).unwrap_or("").trim();
|
||||
if !label.is_empty() {
|
||||
labels.push(label.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to GitHub issue format
|
||||
let issue_content = convert_to_github_issue(
|
||||
&summary, &issue_key, &issue_type, &priority,
|
||||
&labels, &description, &assignee, &reporter,
|
||||
&created, &status
|
||||
);
|
||||
|
||||
// Generate a filename based on the issue key
|
||||
// Sanitize issue key to create a valid filename
|
||||
let safe_issue_key = sanitize_filename(issue_key);
|
||||
let filename = format!("{}.md", safe_issue_key);
|
||||
let filepath = batch_dir.join(&filename);
|
||||
|
||||
debug!("Writing issue to file: {}", filepath.display());
|
||||
|
||||
// Write to individual file
|
||||
let mut file = File::create(&filepath)
|
||||
.with_context(|| format!("Failed to create output file: {}", filepath.display()))?;
|
||||
file.write_all(issue_content.as_bytes())?;
|
||||
|
||||
// Also append to batch file
|
||||
writeln!(batch_file, "---ISSUE---\n")?;
|
||||
writeln!(batch_file, "{}", issue_content)?;
|
||||
|
||||
stats.successful_conversions += 1;
|
||||
}
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
|
||||
info!("Successfully converted {} Jira issues to GitHub issue format", stats.successful_conversions);
|
||||
println!("\nSuccessfully converted {} Jira issues to GitHub issue format", stats.successful_conversions);
|
||||
println!("Individual issue files are in: {}", batch_dir.display());
|
||||
println!("Batch file is at: {}", batch_dir.join("batch.md").display());
|
||||
println!("Error log is at: {}", batch_dir.join("error_log.txt").display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate CSV headers for required fields
|
||||
fn validate_headers(headers: &csv::StringRecord, required_fields: &[&str]) -> Result<()> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
for &field in required_fields {
|
||||
if !headers.iter().any(|h| h == field) {
|
||||
missing.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
return Err(anyhow!("Missing required columns in CSV: {}. Please ensure your Jira export includes these fields.",
|
||||
missing.join(", ")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize a string to be used as a filename
|
||||
fn sanitize_filename(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Safely get a field by column name, handling missing columns
|
||||
fn get_field_by_name(record: &csv::StringRecord, headers: &csv::StringRecord, name: &str) -> String {
|
||||
for (i, header) in headers.iter().enumerate() {
|
||||
if header == name && i < record.len() {
|
||||
return record.get(i).unwrap_or("").to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Convert a Jira issue to GitHub issue markdown format
|
||||
fn convert_to_github_issue(
|
||||
summary: &str,
|
||||
issue_key: &str,
|
||||
issue_type: &str,
|
||||
priority: &str,
|
||||
labels: &[String],
|
||||
description: &str,
|
||||
assignee: &str,
|
||||
reporter: &str,
|
||||
created: &str,
|
||||
default_status: &str
|
||||
) -> String {
|
||||
// Determine appropriate labels based on issue type and priority
|
||||
let mut gh_labels = Vec::new();
|
||||
|
||||
// Add issue type as a label
|
||||
let issue_type_label = match issue_type {
|
||||
"Bug" | "Bug⚠️" => "bug",
|
||||
"Feature" | "Feature🔺" => "enhancement",
|
||||
"Refactor" | "Refactor♻️" => "refactor",
|
||||
"Task" | "Task✔️" => "task",
|
||||
_ => "other",
|
||||
};
|
||||
gh_labels.push(issue_type_label.to_string());
|
||||
|
||||
// Add priority as a label if significant
|
||||
if priority.contains("High") || priority.contains("Critical") {
|
||||
gh_labels.push("high-priority".to_string());
|
||||
}
|
||||
|
||||
// Add any Jira labels
|
||||
for label in labels {
|
||||
for l in label.split(',') {
|
||||
let trimmed = l.trim();
|
||||
if !trimmed.is_empty() {
|
||||
gh_labels.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format labels for YAML front matter
|
||||
let labels_yaml = if gh_labels.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
let mut result = "labels:\n".to_string();
|
||||
for label in gh_labels {
|
||||
result.push_str(&format!(" - {}\n", label));
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
// Format assignees for YAML front matter
|
||||
let assignees_yaml = if assignee.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!("assignees:\n - {}\n", assignee)
|
||||
};
|
||||
|
||||
// Format the description, replacing any Jira formatting with Markdown
|
||||
let formatted_description = description
|
||||
.replace("\\n", "\n")
|
||||
.replace("{noformat}", "```")
|
||||
.replace("!image-", "![image-");
|
||||
|
||||
// Create GitHub issue content with YAML front matter
|
||||
let content = format!(
|
||||
"---\ntitle: {}\nstatus: {}\n{}{}---\n\n# {}\n\n## Description\n\n{}\n\n## Metadata\n\n- **Jira Issue**: {}\n- **Created**: {}\n- **Reporter**: {}\n",
|
||||
summary,
|
||||
default_status,
|
||||
labels_yaml,
|
||||
assignees_yaml,
|
||||
summary,
|
||||
formatted_description,
|
||||
issue_key,
|
||||
created,
|
||||
reporter
|
||||
);
|
||||
|
||||
content
|
||||
}
|
||||
Reference in New Issue
Block a user