pub mod templates;
pub mod types;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Result;
use indicatif::{ProgressBar, ProgressStyle};
use tokio::fs;
use crate::cache::Cache;
use crate::fetcher::GitHubFetcher;
use self::templates::{BASE_TYPES_TEMPLATE, JOB_WORKFLOW_RUNTIME_TEMPLATE};
use self::types::generate_type_definition;
pub struct TypeGenerator {
fetcher: GitHubFetcher,
output_dir: PathBuf,
}
impl TypeGenerator {
pub fn new(
cache: Cache,
output_dir: PathBuf,
token: Option<String>,
api_url: Option<String>,
) -> Self {
Self::with_cache_ttl(cache, output_dir, token, api_url, 30)
}
pub fn with_cache_ttl(
cache: Cache,
output_dir: PathBuf,
token: Option<String>,
api_url: Option<String>,
cache_ttl_days: u64,
) -> Self {
Self {
fetcher: GitHubFetcher::new(cache, token, api_url, cache_ttl_days),
output_dir,
}
}
pub async fn generate_types_for_refs(
&self,
action_refs: &HashSet<String>,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(&self.output_dir).await?;
let mut generated_files = Vec::new();
let base_path = self.generate_base_types().await?;
generated_files.push(base_path);
let mut action_infos = Vec::new();
let pb = ProgressBar::new(action_refs.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("━━─"),
);
for action_ref in action_refs {
pb.set_message(action_ref.clone());
match self.generate_type_for_ref(action_ref).await {
Ok((path, info)) => {
generated_files.push(path);
action_infos.push(info);
}
Err(e) => {
pb.suspend(|| {
eprintln!("Failed to generate types for {}: {}", action_ref, e);
});
}
}
pb.inc(1);
}
pb.finish_and_clear();
let old_index_ts = self.output_dir.join("index.ts");
if old_index_ts.exists() {
let _ = fs::remove_file(&old_index_ts).await;
}
self.generate_index_dts(&action_infos).await?;
self.generate_index_js(&action_infos).await?;
Ok(generated_files)
}
async fn generate_base_types(&self) -> Result<PathBuf> {
let content = BASE_TYPES_TEMPLATE.to_string();
let file_path = self.output_dir.join("base.d.ts");
fs::write(&file_path, content).await?;
Ok(file_path)
}
async fn generate_type_for_ref(&self, action_ref: &str) -> Result<(PathBuf, ActionTypeInfo)> {
let metadata = self.fetcher.fetch_action_metadata(action_ref).await?;
let type_def = generate_type_definition(action_ref, &metadata);
let interface_name = action_ref_to_interface_name(action_ref);
let module_name = action_ref_to_module_name(action_ref);
let has_outputs = metadata
.outputs
.as_ref()
.map(|outputs| !outputs.is_empty())
.unwrap_or(false);
let filename = action_ref_to_filename(action_ref);
let file_path = self.output_dir.join(&filename);
fs::write(&file_path, type_def).await?;
Ok((
file_path,
ActionTypeInfo {
action_ref: action_ref.to_string(),
interface_name,
module_name,
has_outputs,
},
))
}
async fn generate_index_dts(&self, action_infos: &[ActionTypeInfo]) -> Result<()> {
let mut sorted_infos = action_infos.to_vec();
sorted_infos.sort_by(|left, right| left.action_ref.cmp(&right.action_ref));
let mut content = String::new();
content.push_str("// Auto-generated by gaji\n// Do not edit manually\n\n");
content.push_str("import type { JobStep, JobDefinition, WorkflowConfig, WorkflowDefinition, Step, Permissions, Service, Container, WorkflowTrigger, ScheduleTrigger, WorkflowDispatchInput, WorkflowOn, ActionInputDefinition, ActionOutputDefinition, JavaScriptActionConfig, JavaScriptActionRuns } from './base';\n");
for info in &sorted_infos {
content.push_str(&format!(
"import type {{ {}Inputs }} from './{}';",
info.interface_name, info.module_name
));
content.push('\n');
}
content.push('\n');
for info in &sorted_infos {
content.push_str(&format!(
r#"export declare function getAction(
ref: '{}'
): (config?: {{
name?: string;
with?: {}Inputs;
id?: string;
if?: string;
env?: Record<string, string>;
}}) => JobStep;
"#,
info.action_ref, info.interface_name
));
}
content.push_str(
r#"
export declare function getAction<T extends string>(
ref: T
): (config?: {
name?: string;
with?: Record<string, unknown>;
id?: string;
if?: string;
env?: Record<string, string>;
}) => JobStep;
"#,
);
content.push_str(r#"
export declare class Job {
constructor(runsOn: string | string[], options?: Partial<JobDefinition>);
addStep(step: JobStep): this;
needs(deps: string | string[]): this;
env(env: Record<string, string>): this;
when(condition: string): this;
permissions(perms: Permissions): this;
outputs(outputs: Record<string, string>): this;
strategy(s: { matrix?: Record<string, unknown>; 'fail-fast'?: boolean; 'max-parallel'?: number }): this;
continueOnError(v: boolean): this;
timeoutMinutes(m: number): this;
toJSON(): JobDefinition;
}
export declare class CompositeJob extends Job {
constructor(runsOn: string | string[], options?: Partial<JobDefinition>);
}
export declare class Workflow {
constructor(config: WorkflowConfig);
addJob(id: string, job: Job | CompositeJob | CallJob): this;
static fromObject(def: WorkflowDefinition, id?: string): Workflow;
toJSON(): WorkflowDefinition;
build(id?: string): void;
}
export declare class CompositeAction {
constructor(config: { name: string; description: string; inputs?: Record<string, unknown>; outputs?: Record<string, unknown> });
addStep(step: JobStep): this;
toJSON(): object;
build(id?: string): void;
}
export declare class JavaScriptAction {
constructor(config: JavaScriptActionConfig, runs: JavaScriptActionRuns);
toJSON(): object;
build(id?: string): void;
}
export declare class CallJob {
constructor(uses: string);
with(inputs: Record<string, unknown>): this;
secrets(s: Record<string, unknown> | 'inherit'): this;
needs(deps: string | string[]): this;
when(condition: string): this;
permissions(perms: Permissions): this;
toJSON(): object;
}
export declare class CallAction {
constructor(uses: string);
static from(action: CompositeAction | JavaScriptAction): CallAction;
toJSON(): JobStep;
}
"#);
content.push_str("\nexport type { JobStep, Step, JobDefinition, Service, Container, Permissions, WorkflowTrigger, ScheduleTrigger, WorkflowDispatchInput, WorkflowOn, WorkflowConfig, WorkflowDefinition, ActionInputDefinition, ActionOutputDefinition, JavaScriptActionConfig, JavaScriptActionRuns } from './base';\n");
for info in &sorted_infos {
if info.has_outputs {
content.push_str(&format!(
"export type {{ {}Inputs, {}Outputs }} from './{}';",
info.interface_name, info.interface_name, info.module_name
));
} else {
content.push_str(&format!(
"export type {{ {}Inputs }} from './{}';",
info.interface_name, info.module_name
));
}
content.push('\n');
}
let path = self.output_dir.join("index.d.ts");
fs::write(path, content).await?;
Ok(())
}
async fn generate_index_js(&self, action_infos: &[ActionTypeInfo]) -> Result<()> {
let mut sorted_infos = action_infos.to_vec();
sorted_infos.sort_by(|left, right| left.action_ref.cmp(&right.action_ref));
let mut content = String::new();
content.push_str("// Auto-generated by gaji\n// Do not edit manually\n\n");
content.push_str(
r#"export function getAction(ref) {
return function(config) {
if (config === undefined) config = {};
var step = {
uses: ref,
};
if (config.name !== undefined) step.name = config.name;
if (config.with !== undefined) step.with = config.with;
if (config.id !== undefined) step.id = config.id;
if (config["if"] !== undefined) step["if"] = config["if"];
if (config.env !== undefined) step.env = config.env;
return step;
};
}
"#,
);
content.push_str(JOB_WORKFLOW_RUNTIME_TEMPLATE);
content.push('\n');
let path = self.output_dir.join("index.js");
fs::write(path, content).await?;
Ok(())
}
}
#[derive(Clone)]
struct ActionTypeInfo {
action_ref: String,
interface_name: String,
module_name: String,
has_outputs: bool,
}
pub fn action_ref_to_filename(action_ref: &str) -> String {
action_ref.replace(['/', '@', '.'], "-") + ".d.ts"
}
pub fn action_ref_to_interface_name(action_ref: &str) -> String {
action_ref
.split(['/', '@', '-', '.'])
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
fn action_ref_to_module_name(action_ref: &str) -> String {
action_ref_to_filename(action_ref)
.trim_end_matches(".d.ts")
.to_string()
}
pub async fn ensure_generated_dir(path: &Path) -> Result<PathBuf> {
let dir = path.to_path_buf();
fs::create_dir_all(&dir).await?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_ref_to_filename() {
assert_eq!(
action_ref_to_filename("actions/checkout@v5"),
"actions-checkout-v5.d.ts"
);
assert_eq!(
action_ref_to_filename("owner/repo/path@main"),
"owner-repo-path-main.d.ts"
);
}
#[test]
fn test_action_ref_to_interface_name() {
assert_eq!(
action_ref_to_interface_name("actions/checkout@v5"),
"ActionsCheckoutV5"
);
assert_eq!(
action_ref_to_interface_name("actions/setup-node@v4"),
"ActionsSetupNodeV4"
);
}
}