use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use colored::Colorize;
pub fn discover_workflows(root: &Path) -> Result<Vec<PathBuf>> {
let workflows_dir = root.join(".github/workflows");
if !workflows_dir.exists() {
return Ok(vec![]);
}
let mut workflows: Vec<PathBuf> = std::fs::read_dir(&workflows_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let ext = entry.path().extension().map(|s| s.to_owned());
ext.as_deref() == Some(OsStr::new("yml")) || ext.as_deref() == Some(OsStr::new("yaml"))
})
.map(|entry| entry.path())
.collect();
workflows.sort();
Ok(workflows)
}
pub async fn migrate_workflows(root: &Path, workflows: &[PathBuf]) -> Result<()> {
println!("{} Migrating workflows to TypeScript...\n", "🔄".cyan());
let ts_dir = root.join("workflows");
tokio::fs::create_dir_all(&ts_dir).await?;
for workflow_path in workflows {
let workflow_name = workflow_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Invalid workflow path: {}", workflow_path.display()))?;
println!(" Migrating {}...", workflow_name);
let yaml_content = tokio::fs::read_to_string(workflow_path).await?;
match generate_typescript_from_yaml(&yaml_content, workflow_name) {
Ok(ts_content) => {
let ts_path = ts_dir.join(format!("{}.ts", workflow_name));
tokio::fs::write(&ts_path, ts_content).await?;
println!(" {} Created {}", "✓".green(), ts_path.display());
let backup_path = workflow_path.with_extension("yml.backup");
tokio::fs::rename(workflow_path, &backup_path).await?;
println!(" {} Backed up to {}", "✓".green(), backup_path.display());
}
Err(e) => {
eprintln!(" {} Failed to migrate {}: {}", "✗".red(), workflow_name, e);
}
}
}
println!("\n{} Migration complete!", "✨".green());
println!(" Review the generated TypeScript files in workflows/");
println!(" Run 'gaji build' to regenerate YAML files\n");
Ok(())
}
fn generate_typescript_from_yaml(yaml_content: &str, workflow_id: &str) -> Result<String> {
let workflow: serde_yaml::Value =
serde_yaml::from_str(yaml_content).map_err(|e| anyhow!("Failed to parse YAML: {}", e))?;
let mut ts = String::new();
ts.push_str("// Migrated from YAML by gaji init --migrate\n");
ts.push_str("// NOTE: This is a basic conversion. Please review and adjust as needed.\n");
ts.push_str("import { getAction, Job, Workflow } from \"../generated/index.js\";\n\n");
let actions = extract_actions_from_yaml(&workflow);
for action in &actions {
let var_name = action_to_var_name(action);
ts.push_str(&format!(
"const {} = getAction(\"{}\");\n",
var_name, action
));
}
if !actions.is_empty() {
ts.push('\n');
}
let name = workflow
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(workflow_id);
if let Some(jobs) = workflow.get("jobs").and_then(|j| j.as_mapping()) {
for (job_id, job_def) in jobs {
let job_id_str = job_id.as_str().unwrap_or("job");
let runs_on = job_def
.get("runs-on")
.and_then(|v| v.as_str())
.unwrap_or("ubuntu-latest");
let var = job_id_str.replace('-', "_");
ts.push_str(&format!("const {} = new Job(\"{}\")\n", var, runs_on));
if let Some(steps) = job_def.get("steps").and_then(|s| s.as_sequence()) {
for step in steps {
generate_step(&mut ts, step, &actions);
}
}
ts.push_str(";\n\n");
}
}
let safe_id = workflow_id.replace('-', "_");
ts.push_str("const workflow = new Workflow({\n");
ts.push_str(&format!(" name: \"{}\",\n", escape_js_string(name)));
let on_value = workflow.get("on").or_else(|| {
workflow
.as_mapping()
.and_then(|map| map.get(serde_yaml::Value::Bool(true)))
});
if let Some(on) = on_value {
ts.push_str(" on: ");
ts.push_str(&yaml_value_to_js(on, 4));
ts.push_str(",\n");
}
ts.push_str("})");
if let Some(jobs) = workflow.get("jobs").and_then(|j| j.as_mapping()) {
for (job_id, _) in jobs {
let job_id_str = job_id.as_str().unwrap_or("job");
let var = job_id_str.replace('-', "_");
ts.push_str(&format!(".addJob(\"{}\", {})", job_id_str, var));
}
}
ts.push_str(";\n\n");
ts.push_str(&format!("workflow.build(\"{}\");\n", safe_id));
Ok(ts)
}
fn generate_step(ts: &mut String, step: &serde_yaml::Value, actions: &[String]) {
if let Some(uses) = step.get("uses").and_then(|v| v.as_str()) {
let var_name = action_to_var_name(uses);
let is_known = actions.iter().any(|a| a == uses);
if is_known {
ts.push_str(&format!(" .addStep({}({{\n", var_name));
} else {
ts.push_str(" .addStep({\n");
ts.push_str(&format!(" uses: \"{}\",\n", escape_js_string(uses)));
}
if let Some(name) = step.get("name").and_then(|v| v.as_str()) {
ts.push_str(&format!(" name: \"{}\",\n", escape_js_string(name)));
}
if let Some(with) = step.get("with").and_then(|v| v.as_mapping()) {
ts.push_str(" with: {\n");
for (k, v) in with {
let key_str = k.as_str().unwrap_or("unknown");
let needs_quotes = key_str.contains('-') || key_str.contains('.');
if needs_quotes {
ts.push_str(&format!(
" \"{}\": {},\n",
escape_js_string(key_str),
yaml_value_to_js(v, 12)
));
} else {
ts.push_str(&format!(
" {}: {},\n",
key_str,
yaml_value_to_js(v, 12)
));
}
}
ts.push_str(" },\n");
}
if let Some(if_cond) = step.get("if").and_then(|v| v.as_str()) {
ts.push_str(&format!(
" \"if\": \"{}\",\n",
escape_js_string(if_cond)
));
}
if let Some(env) = step.get("env").and_then(|v| v.as_mapping()) {
ts.push_str(" env: {\n");
for (k, v) in env {
let key_str = k.as_str().unwrap_or("unknown");
ts.push_str(&format!(
" {}: {},\n",
key_str,
yaml_value_to_js(v, 12)
));
}
ts.push_str(" },\n");
}
if is_known {
ts.push_str(" }))\n");
} else {
ts.push_str(" })\n");
}
} else if let Some(run) = step.get("run").and_then(|v| v.as_str()) {
ts.push_str(" .addStep({\n");
if let Some(name) = step.get("name").and_then(|v| v.as_str()) {
ts.push_str(&format!(" name: \"{}\",\n", escape_js_string(name)));
}
if run.contains('\n') {
ts.push_str(&format!(
" run: `{}`",
run.replace('`', "\\`").replace("${", "\\${")
));
} else {
ts.push_str(&format!(" run: \"{}\"", escape_js_string(run)));
}
ts.push_str(",\n");
if let Some(if_cond) = step.get("if").and_then(|v| v.as_str()) {
ts.push_str(&format!(
" \"if\": \"{}\",\n",
escape_js_string(if_cond)
));
}
if let Some(env) = step.get("env").and_then(|v| v.as_mapping()) {
ts.push_str(" env: {\n");
for (k, v) in env {
let key_str = k.as_str().unwrap_or("unknown");
ts.push_str(&format!(
" {}: {},\n",
key_str,
yaml_value_to_js(v, 12)
));
}
ts.push_str(" },\n");
}
ts.push_str(" })\n");
}
}
pub fn extract_actions_from_yaml(workflow: &serde_yaml::Value) -> Vec<String> {
let mut actions = Vec::new();
if let Some(jobs) = workflow.get("jobs").and_then(|j| j.as_mapping()) {
for (_, job) in jobs {
if let Some(steps) = job.get("steps").and_then(|s| s.as_sequence()) {
for step in steps {
if let Some(uses) = step.get("uses").and_then(|v| v.as_str()) {
if !actions.contains(&uses.to_string()) {
actions.push(uses.to_string());
}
}
}
}
}
}
actions.sort();
actions.dedup();
actions
}
pub fn action_to_var_name(action: &str) -> String {
let base = action
.split('/')
.next_back()
.unwrap_or("action")
.split('@')
.next()
.unwrap_or("action");
let mut result = String::new();
let mut capitalize_next = false;
for ch in base.chars() {
if ch == '-' || ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.extend(ch.to_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
fn yaml_value_to_js(value: &serde_yaml::Value, indent: usize) -> String {
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
match value {
serde_yaml::Value::Null => "undefined".to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::String(s) => {
if s.contains('\n') {
format!("`{}`", s.replace('`', "\\`").replace("${", "\\${"))
} else {
format!("\"{}\"", escape_js_string(s))
}
}
serde_yaml::Value::Sequence(seq) => {
if seq.is_empty() {
return "[]".to_string();
}
let all_simple = seq.iter().all(|v| {
matches!(
v,
serde_yaml::Value::String(_)
| serde_yaml::Value::Number(_)
| serde_yaml::Value::Bool(_)
)
});
if all_simple {
let items: Vec<String> = seq.iter().map(|v| yaml_value_to_js(v, 0)).collect();
format!("[{}]", items.join(", "))
} else {
let mut result = String::from("[\n");
for item in seq {
result.push_str(&inner_pad);
result.push_str(&yaml_value_to_js(item, indent + 4));
result.push_str(",\n");
}
result.push_str(&pad);
result.push(']');
result
}
}
serde_yaml::Value::Mapping(map) => {
if map.is_empty() {
return "{}".to_string();
}
let mut result = String::from("{\n");
for (k, v) in map {
let key_str = match k {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Bool(b) => b.to_string(),
other => format!("{:?}", other),
};
let needs_quotes = key_str.contains('-')
|| key_str.contains('.')
|| key_str.contains(' ')
|| key_str.starts_with(|c: char| c.is_ascii_digit());
if needs_quotes {
result.push_str(&format!(
"{}\"{}\": {},\n",
inner_pad,
escape_js_string(&key_str),
yaml_value_to_js(v, indent + 4)
));
} else {
result.push_str(&format!(
"{}{}: {},\n",
inner_pad,
key_str,
yaml_value_to_js(v, indent + 4)
));
}
}
result.push_str(&pad);
result.push('}');
result
}
_ => "undefined".to_string(),
}
}
fn escape_js_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_to_var_name() {
assert_eq!(action_to_var_name("actions/checkout@v5"), "checkout");
assert_eq!(action_to_var_name("actions/setup-node@v4"), "setupNode");
assert_eq!(
action_to_var_name("codecov/codecov-action@v3"),
"codecovAction"
);
assert_eq!(action_to_var_name("actions/cache@v3"), "cache");
assert_eq!(
action_to_var_name("dtolnay/rust-toolchain@stable"),
"rustToolchain"
);
}
#[test]
fn test_extract_actions_from_yaml() {
let yaml: serde_yaml::Value = serde_yaml::from_str(
r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- name: Test
run: npm test
"#,
)
.unwrap();
let actions = extract_actions_from_yaml(&yaml);
assert_eq!(
actions,
vec!["actions/checkout@v5", "actions/setup-node@v4"]
);
}
#[test]
fn test_generate_typescript_from_yaml_basic() {
let yaml_content = r#"
name: CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Test
run: npm test
"#;
let ts = generate_typescript_from_yaml(yaml_content, "ci").unwrap();
assert!(ts.contains("import { getAction, Job, Workflow }"));
assert!(ts.contains(r#"getAction("actions/checkout@v5")"#));
assert!(ts.contains("new Job("));
assert!(ts.contains("new Workflow("));
assert!(ts.contains(r#"workflow.build("ci")"#));
}
#[test]
fn test_yaml_value_to_js_string() {
let val = serde_yaml::Value::String("hello".to_string());
assert_eq!(yaml_value_to_js(&val, 0), "\"hello\"");
}
#[test]
fn test_yaml_value_to_js_array() {
let val: serde_yaml::Value = serde_yaml::from_str("[main, dev]").unwrap();
assert_eq!(yaml_value_to_js(&val, 0), "[\"main\", \"dev\"]");
}
#[test]
fn test_yaml_value_to_js_number() {
let val = serde_yaml::Value::Number(serde_yaml::Number::from(42));
assert_eq!(yaml_value_to_js(&val, 0), "42");
}
#[test]
fn test_yaml_value_to_js_bool() {
let val = serde_yaml::Value::Bool(true);
assert_eq!(yaml_value_to_js(&val, 0), "true");
}
#[test]
fn test_escape_js_string() {
assert_eq!(escape_js_string(r#"say "hello""#), r#"say \"hello\""#);
assert_eq!(escape_js_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn test_yaml_value_to_js_multiline_string_escapes_dollar_brace() {
let val = serde_yaml::Value::String("echo ${{ secrets.TOKEN }}\necho done".to_string());
let result = yaml_value_to_js(&val, 0);
assert!(
result.contains("\\${"),
"Expected \\${{ but got: {}",
result
);
}
#[test]
fn test_generate_step_multiline_run_escapes_dollar_brace() {
let yaml_content = r#"
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Multi-line with expression
run: |
echo ${{ secrets.TOKEN }}
echo done
"#;
let ts = generate_typescript_from_yaml(yaml_content, "ci").unwrap();
assert!(
ts.contains("\\${{ secrets.TOKEN }}"),
"Expected escaped \\${{ but got: {}",
ts
);
}
#[test]
fn test_discover_workflows_empty() {
let temp = tempfile::TempDir::new().unwrap();
let result = discover_workflows(temp.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_discover_workflows_finds_yml() {
let temp = tempfile::TempDir::new().unwrap();
let wf_dir = temp.path().join(".github/workflows");
std::fs::create_dir_all(&wf_dir).unwrap();
std::fs::write(wf_dir.join("ci.yml"), "name: CI").unwrap();
std::fs::write(wf_dir.join("release.yaml"), "name: Release").unwrap();
std::fs::write(wf_dir.join("readme.md"), "not a workflow").unwrap();
let result = discover_workflows(temp.path()).unwrap();
assert_eq!(result.len(), 2);
}
}