Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/hm-dsl-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ pub struct PipelineMeta {
pub trait DslEngine: Send + Sync {
async fn list_pipelines(&self, project_dir: &Path) -> anyhow::Result<Vec<PipelineMeta>>;
async fn render_pipeline_json(&self, project_dir: &Path, slug: &str) -> anyhow::Result<String>;
/// Emit the full discovery envelope JSON for every pipeline in the repo:
/// `{"schema_version": "...", "pipelines": [{slug, name, allow_manual,
/// triggers, definition}, ...]}`. Returned verbatim from the DSL runtime so
/// the backend's pipeline discovery can consume it directly.
async fn registry_json(&self, project_dir: &Path) -> anyhow::Result<String>;
}

/// Return an appropriate [`DslEngine`] for the given language.
Expand Down
20 changes: 20 additions & 0 deletions crates/hm-dsl-engine/src/python_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ envelope = json.loads(hm.dump_registry_json())
print(json.dumps([{'slug': p['slug'], 'name': p['name']} for p in envelope['pipelines']]))
";

const REGISTRY_JSON_SCRIPT: &str = "\
import sys, pathlib, importlib.util
try:
import harmont as hm
except ImportError as e:
print(f'error: {e}\\n -> install with: pip install croniter python-dateutil', file=sys.stderr)
sys.exit(1)
for p in sorted(pathlib.Path('.harmont').glob('*.py')):
spec = importlib.util.spec_from_file_location(f'_harmont_{p.stem}', p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
sys.stdout.write(hm.dump_registry_json())
";

const RENDER_PIPELINE_SCRIPT: &str = "\
import sys, json, pathlib, importlib.util
try:
Expand Down Expand Up @@ -114,4 +128,10 @@ impl DslEngine for SubprocessPythonEngine {
.await
.context("rendering pipeline via python3")
}

async fn registry_json(&self, project_dir: &Path) -> Result<String> {
self.run_script(project_dir, REGISTRY_JSON_SCRIPT, &[])
.await
.context("dumping pipeline registry via python3")
}
}
7 changes: 7 additions & 0 deletions crates/hm-dsl-engine/src/ts_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,11 @@ impl DslEngine for SubprocessTsEngine {
.await
.context("rendering pipeline via JS runtime")
}

async fn registry_json(&self, _project_dir: &Path) -> Result<String> {
bail!(
"the discovery envelope (hm pipelines) is not yet supported for \
TypeScript pipelines; only Python pipelines are supported today"
)
}
}
34 changes: 34 additions & 0 deletions crates/hm-dsl-engine/tests/python_engine_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,37 @@ def ci() -> hm.Step:
let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(v["version"], "0");
}

#[tokio::test]
async fn python_registry_json_carries_triggers_and_allow_manual() {
if which::which("python3").is_err() {
eprintln!("skipping: python3 not on PATH");
return;
}

let dir = tempfile::tempdir().unwrap();
let harmont = dir.path().join(".harmont");
std::fs::create_dir_all(&harmont).unwrap();
std::fs::write(
harmont.join("ci.py"),
r#"import harmont as hm

@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')], allow_manual=False)
def ci() -> hm.Step:
return hm.scratch().sh('echo test', label='test')
"#,
)
.unwrap();

let engine = hm_dsl_engine::engine_for(hm_dsl_engine::DslLanguage::Python).unwrap();
let json = engine.registry_json(dir.path()).await.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();

let p = &v["pipelines"][0];
assert_eq!(p["slug"], "ci");
assert_eq!(p["name"], "CI");
assert_eq!(p["allow_manual"], false);
assert_eq!(p["triggers"][0]["event"], "push");
assert_eq!(p["triggers"][0]["branches"][0], "main");
assert_eq!(p["definition"]["version"], "0");
}
10 changes: 10 additions & 0 deletions crates/hm/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
pub mod pipelines;
pub mod plugin;
pub mod render;
pub mod run;
pub mod version;

Expand Down Expand Up @@ -45,6 +47,12 @@ pub enum Command {
/// Run a pipeline locally via Docker.
Run(RunArgs),

/// Print the pipeline discovery envelope (JSON) for every pipeline in the repo.
Pipelines(pipelines::PipelinesArgs),

/// Render one pipeline's v0 IR (JSON) without running it.
Render(render::RenderArgs),

/// Show hm version.
Version,

Expand Down Expand Up @@ -91,6 +99,8 @@ pub struct CacheRestoreArgs {
pub async fn dispatch(command: Command, ctx: RunContext) -> Result<i32> {
match command {
Command::Run(args) => crate::commands::run::handle(args, ctx).await,
Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0),
Command::Render(args) => crate::cli::render::run(args).await.map(|()| 0),
Command::Cache(cmd) => match cmd {
CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await,
CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await,
Expand Down
36 changes: 36 additions & 0 deletions crates/hm/src/cli/pipelines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::Parser;
use hm_dsl_engine::{detect, engine_for};

#[derive(Debug, Clone, Parser)]
pub struct PipelinesArgs {
/// Source root containing `.harmont/` (defaults to cwd).
#[arg(short, long)]
pub dir: Option<PathBuf>,
}

/// Print the discovery envelope JSON (all pipelines) to stdout.
///
/// # Errors
///
/// Returns an error if the language can't be detected, the engine can't start,
/// or the DSL runtime fails to evaluate the pipelines.
pub async fn run(args: PipelinesArgs) -> Result<()> {
let repo_root = match args.dir {
Some(d) => d,
None => std::env::current_dir().context("cannot determine current directory")?,
};

let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
let engine = engine_for(lang).context("initializing DSL engine")?;
let json = engine
.registry_json(&repo_root)
.await
.context("dumping pipeline registry")?;

// Machine-facing: raw envelope JSON on stdout, nothing else.
print!("{json}");
Ok(())
}
41 changes: 41 additions & 0 deletions crates/hm/src/cli/render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::Parser;
use hm_dsl_engine::{detect, engine_for};

#[derive(Debug, Clone, Parser)]
pub struct RenderArgs {
/// Pipeline slug to render.
#[arg()]
pub slug: String,

/// Source root containing `.harmont/` (defaults to cwd).
#[arg(short, long)]
pub dir: Option<PathBuf>,
}

/// Render one pipeline's v0 IR JSON to stdout without executing it.
///
/// # Errors
///
/// Returns an error if the language can't be detected, the engine can't start,
/// or the slug is unknown / fails to render (the available slugs are written to
/// stderr by the DSL runtime).
pub async fn run(args: RenderArgs) -> Result<()> {
let repo_root = match args.dir {
Some(d) => d,
None => std::env::current_dir().context("cannot determine current directory")?,
};

let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
let engine = engine_for(lang).context("initializing DSL engine")?;
let json = engine
.render_pipeline_json(&repo_root, &args.slug)
.await
.with_context(|| format!("rendering pipeline {:?}", args.slug))?;

// Machine-facing: raw v0 IR JSON on stdout, nothing else.
print!("{json}");
Ok(())
}
47 changes: 47 additions & 0 deletions crates/hm/tests/cmd_pipelines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! `hm pipelines` emits the discovery envelope JSON to stdout.

#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stderr
)]

use assert_cmd::Command;

#[test]
fn pipelines_emits_discovery_envelope() {
if which::which("python3").is_err() {
eprintln!("skipping: python3 not on PATH");
return;
}

let dir = tempfile::tempdir().unwrap();
let harmont = dir.path().join(".harmont");
std::fs::create_dir_all(&harmont).unwrap();
std::fs::write(
harmont.join("ci.py"),
r"import harmont as hm

@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')])
def ci() -> hm.Step:
return hm.scratch().sh('echo test', label='test')
",
)
.unwrap();

let out = Command::cargo_bin("hm")
.unwrap()
.arg("pipelines")
.arg("--dir")
.arg(dir.path())
.assert()
.success()
.get_output()
.stdout
.clone();

let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["pipelines"][0]["slug"], "ci");
assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push");
}
78 changes: 78 additions & 0 deletions crates/hm/tests/cmd_render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! `hm render <slug>` emits one pipeline's v0 IR JSON to stdout.

#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stderr
)]

use assert_cmd::Command;

fn write_fixture(dir: &std::path::Path) {
let harmont = dir.join(".harmont");
std::fs::create_dir_all(&harmont).unwrap();
std::fs::write(
harmont.join("ci.py"),
r"import harmont as hm

@hm.pipeline('ci')
def ci() -> hm.Step:
return hm.scratch().sh('echo test', label='test')
",
)
.unwrap();
}

#[test]
fn render_emits_v0_ir_for_slug() {
if which::which("python3").is_err() {
eprintln!("skipping: python3 not on PATH");
return;
}

let dir = tempfile::tempdir().unwrap();
write_fixture(dir.path());

let out = Command::cargo_bin("hm")
.unwrap()
.arg("render")
.arg("ci")
.arg("--dir")
.arg(dir.path())
.assert()
.success()
.get_output()
.stdout
.clone();

let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["version"], "0");
assert!(
v["graph"].is_object(),
"expected a graph object in the v0 IR, got: {v}"
);
}

#[test]
fn render_unknown_slug_fails_with_available_on_stderr() {
if which::which("python3").is_err() {
eprintln!("skipping: python3 not on PATH");
return;
}

let dir = tempfile::tempdir().unwrap();
write_fixture(dir.path());

Command::cargo_bin("hm")
.unwrap()
.arg("render")
.arg("nope")
.arg("--dir")
.arg(dir.path())
.assert()
.failure()
// Both the bad slug and the list of available slugs must reach stderr.
.stderr(predicates::str::contains("nope"))
.stderr(predicates::str::contains("available: ci"));
}
Loading