From a85a867252742c5976e08e0664498ccc600db227 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:22:36 +0000 Subject: [PATCH 1/2] feat(cloud): submit by repo+source, watch by the returned global slug hm run derives owner/repo from the git remote and submits via submit_repo_build (POST /organizations/:org/builds), which resolves the pipeline by (repo_name, source_slug). Watch/cancel use the global slug the server returns on the build. --- crates/hm-exec/src/cloud/backend.rs | 29 ++++++++--- crates/hm-exec/src/request.rs | 4 ++ crates/hm-exec/tests/backend_contract.rs | 1 + crates/hm/src/commands/run/mod.rs | 65 ++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-exec/src/cloud/backend.rs index e12061bd..c8b30148 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-exec/src/cloud/backend.rs @@ -2,7 +2,7 @@ //! to Harmont Cloud, and watch it to completion. The server schedules and runs; //! this backend is an *observer* (see [`Capabilities::cloud`]). -use harmont_cloud::{HarmontClient, HarmontError, builds::NewBuild}; +use harmont_cloud::{HarmontClient, HarmontError, builds::NewRepoBuild}; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -84,13 +84,23 @@ impl ExecutionBackend for CloudBackend { // the size so the upload isn't a silent gulf of evaluation. guard_archive_size(source_tgz.len(), &req.repo_root)?; - // Submit. Fail fast on auth/rejection BEFORE returning a handle so the - // CLI can surface the doctrine error without a half-started stream. + // Resolve the repo's `owner/repo`; cloud runs address the pipeline by + // (repo_name, source_slug) — the bare DSL slug is not the server's + // global slug. A worktree with no remote can't be matched to a pipeline. + let repo_name = req.source.repo_name.clone().ok_or_else(|| { + BackendError::Local( + "cloud runs need a git remote to identify the pipeline — add an \ + `origin` remote, or run from a cloned repo" + .into(), + ) + })?; + let build = self .client - .submit_build(NewBuild { + .submit_repo_build(NewRepoBuild { org: self.org.clone(), - pipeline: req.pipeline_slug.clone(), + repo_name, + source_slug: req.pipeline_slug.clone(), branch: req.source.branch.clone(), commit: req.source.commit.clone(), message: req.source.message.clone(), @@ -101,6 +111,10 @@ impl ExecutionBackend for CloudBackend { .await .map_err(map_harmont_err)?; + // The server resolved (and returns) the global slug; watch/cancel/log + // endpoints are addressed by it, NOT by the source slug we submitted. + let pipeline = build.pipeline_slug.clone(); + // Build the dashboard URL from the app host (NOT the API host) and the // SPA route shape `/:orgSlug/pipelines/:slug/builds/:number`. A link // built from `api_base` or without the `pipelines/` segment is @@ -108,14 +122,14 @@ impl ExecutionBackend for CloudBackend { let watch_url = Some(dashboard_build_url( &self.app_base, &self.org, - &req.pipeline_slug, + &pipeline, build.number, )); let build_ref = BuildRef { run_id: uuid::Uuid::new_v4(), number: Some(build.number), org: Some(self.org.clone()), - pipeline: req.pipeline_slug.clone(), + pipeline: pipeline.clone(), }; let (tx, rx) = mpsc::channel(1024); @@ -151,7 +165,6 @@ impl ExecutionBackend for CloudBackend { let client = self.client.clone(); let api_base = self.api_base.clone(); let org = self.org.clone(); - let pipeline = req.pipeline_slug.clone(); let number = build.number; let token = cancel.clone(); let started = chrono::Utc::now(); diff --git a/crates/hm-exec/src/request.rs b/crates/hm-exec/src/request.rs index faf8aa5e..30b4edc8 100644 --- a/crates/hm-exec/src/request.rs +++ b/crates/hm-exec/src/request.rs @@ -63,6 +63,10 @@ pub struct SourceMeta { pub branch: String, pub commit: String, pub message: Option, + /// `owner/repo` from the worktree's git remote, when one exists. `None` for + /// a remoteless worktree; the cloud backend requires it to resolve the + /// pipeline and errors clearly when it is absent. + pub repo_name: Option, } /// Per-run execution options threaded through from the CLI flags. diff --git a/crates/hm-exec/tests/backend_contract.rs b/crates/hm-exec/tests/backend_contract.rs index d920da93..dae86507 100644 --- a/crates/hm-exec/tests/backend_contract.rs +++ b/crates/hm-exec/tests/backend_contract.rs @@ -130,6 +130,7 @@ fn fake_request() -> RunRequest { branch: "main".into(), commit: "0".repeat(40), message: None, + repo_name: None, }, options: RunOptions { watch: true, diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index e5f4b7a9..8c92e320 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -123,6 +123,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // 7. Assemble the run request. let (branch, commit) = git_metadata(&repo_root, args.branch.clone()); + let repo_name = git_remote_repo_name(&repo_root); let req = hm_exec::RunRequest { plan, repo_root, @@ -132,6 +133,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { branch, commit, message: args.message.clone(), + repo_name, }, options: hm_exec::RunOptions { no_cache: false, @@ -202,6 +204,43 @@ fn git_metadata(root: &std::path::Path, branch_override: Option) -> (Str (branch, commit) } +/// Parse `owner/repo` from a git remote URL, mirroring the backend's +/// `Harmont.Pipelines.RepoName`: drop scheme/host and a trailing `.git`, then +/// take the last two non-empty path segments. `None` when fewer than two +/// segments remain. +fn parse_repo_name(url: &str) -> Option { + let url = url.trim(); + let path = if let Some((_, rest)) = url.split_once("://") { + // scheme://host/owner/repo → strip host + rest.split_once('/').map_or(rest, |(_, p)| p) + } else if url.contains('@') && url.contains(':') { + // scp-style git@host:owner/repo → strip "git@host:" + let after_at = url.split_once('@').map_or(url, |(_, r)| r); + after_at.split_once(':').map_or(after_at, |(_, p)| p) + } else { + url.split_once('/').map_or(url, |(_, p)| p) + }; + let path = path.trim_end_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segs.len() < 2 { + return None; + } + Some(segs[segs.len() - 2..].join("/")) +} + +/// Best-effort `owner/repo` from the worktree's `origin` remote. +fn git_remote_repo_name(root: &std::path::Path) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok() + .filter(|o| o.status.success())?; + parse_repo_name(&String::from_utf8_lossy(&out.stdout)) +} + /// Resolve repo root, detect the DSL, select the pipeline slug, and render /// the v0 IR JSON. Shared by local and cloud runs. /// @@ -375,6 +414,32 @@ error[backend]: {other} mod tests { use super::*; + #[test] + fn parse_repo_name_handles_https_ssh_and_scp() { + assert_eq!( + parse_repo_name("https://github.com/harmont-dev/harmont-cli.git").as_deref(), + Some("harmont-dev/harmont-cli") + ); + assert_eq!( + parse_repo_name("git@github.com:harmont-dev/harmont-cli.git").as_deref(), + Some("harmont-dev/harmont-cli") + ); + assert_eq!( + parse_repo_name("ssh://git@github.com/harmont-dev/harmont-cli").as_deref(), + Some("harmont-dev/harmont-cli") + ); + assert_eq!( + parse_repo_name("https://example.com/a/b/c/repo").as_deref(), + Some("c/repo") + ); + } + + #[test] + fn parse_repo_name_rejects_unparseable() { + assert_eq!(parse_repo_name(""), None); + assert_eq!(parse_repo_name("not-a-url"), None); + } + #[test] fn parse_env_splits_pairs() { let m = parse_env(&["A=1".into(), "B=x=y".into(), "bad".into()]); From 327e47ed89b7547d15c6b2425c86850fb888a86a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:40:29 +0000 Subject: [PATCH 2/2] chore: lock harmont-cloud 0.1.3 (published) repo+source build submission is now available from crates.io. --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fed209a..b8ef7f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1137,9 +1137,9 @@ dependencies = [ [[package]] name = "harmont-cloud" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833183d382d5c1f55b823e559be89dc7d82fe9f00190def8f77b6df3819006df" +checksum = "0a55df2e5c3f98237b9949e4a75b7def80210a6a3ff798ac55566d81137e0c44" dependencies = [ "base64", "bytes", @@ -1158,9 +1158,9 @@ dependencies = [ [[package]] name = "harmont-cloud-raw" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654800f36059243441476599250463b7f10de96be79a03d02101630c375e42c3" +checksum = "365765cdef9c7aa758bb409ae312f599d63e6984dd69f21af946181e09433ab3" dependencies = [ "bytes", "chrono",