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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 21 additions & 8 deletions crates/hm-exec/src/cloud/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -101,21 +111,25 @@ 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
// unclickable — it lands on raw JSON or a 404.
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);
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions crates/hm-exec/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ pub struct SourceMeta {
pub branch: String,
pub commit: String,
pub message: Option<String>,
/// `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<String>,
}

/// Per-run execution options threaded through from the CLI flags.
Expand Down
1 change: 1 addition & 0 deletions crates/hm-exec/tests/backend_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ fn fake_request() -> RunRequest {
branch: "main".into(),
commit: "0".repeat(40),
message: None,
repo_name: None,
},
options: RunOptions {
watch: true,
Expand Down
65 changes: 65 additions & 0 deletions crates/hm/src/commands/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {

// 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,
Expand All @@ -132,6 +133,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
branch,
commit,
message: args.message.clone(),
repo_name,
},
options: hm_exec::RunOptions {
no_cache: false,
Expand Down Expand Up @@ -202,6 +204,43 @@ fn git_metadata(root: &std::path::Path, branch_override: Option<String>) -> (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<String> {
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<String> {
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.
///
Expand Down Expand Up @@ -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()]);
Expand Down
Loading