Skip to content

Commit 36a52b7

Browse files
fix: cloud runs resolve the pipeline by repo identity (#139)
1 parent 721a0e6 commit 36a52b7

5 files changed

Lines changed: 95 additions & 12 deletions

File tree

‎Cargo.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/hm-exec/src/cloud/backend.rs‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//! to Harmont Cloud, and watch it to completion. The server schedules and runs;
33
//! this backend is an *observer* (see [`Capabilities::cloud`]).
44
5-
use harmont_cloud::{HarmontClient, HarmontError, builds::NewBuild};
5+
use harmont_cloud::{HarmontClient, HarmontError, builds::NewRepoBuild};
66
use hm_plugin_protocol::events::{BuildEvent, BuildRef};
77
use tokio::sync::mpsc;
88
use tokio_util::sync::CancellationToken;
@@ -84,13 +84,23 @@ impl ExecutionBackend for CloudBackend {
8484
// the size so the upload isn't a silent gulf of evaluation.
8585
guard_archive_size(source_tgz.len(), &req.repo_root)?;
8686

87-
// Submit. Fail fast on auth/rejection BEFORE returning a handle so the
88-
// CLI can surface the doctrine error without a half-started stream.
87+
// Resolve the repo's `owner/repo`; cloud runs address the pipeline by
88+
// (repo_name, source_slug) — the bare DSL slug is not the server's
89+
// global slug. A worktree with no remote can't be matched to a pipeline.
90+
let repo_name = req.source.repo_name.clone().ok_or_else(|| {
91+
BackendError::Local(
92+
"cloud runs need a git remote to identify the pipeline — add an \
93+
`origin` remote, or run from a cloned repo"
94+
.into(),
95+
)
96+
})?;
97+
8998
let build = self
9099
.client
91-
.submit_build(NewBuild {
100+
.submit_repo_build(NewRepoBuild {
92101
org: self.org.clone(),
93-
pipeline: req.pipeline_slug.clone(),
102+
repo_name,
103+
source_slug: req.pipeline_slug.clone(),
94104
branch: req.source.branch.clone(),
95105
commit: req.source.commit.clone(),
96106
message: req.source.message.clone(),
@@ -101,21 +111,25 @@ impl ExecutionBackend for CloudBackend {
101111
.await
102112
.map_err(map_harmont_err)?;
103113

114+
// The server resolved (and returns) the global slug; watch/cancel/log
115+
// endpoints are addressed by it, NOT by the source slug we submitted.
116+
let pipeline = build.pipeline_slug.clone();
117+
104118
// Build the dashboard URL from the app host (NOT the API host) and the
105119
// SPA route shape `/:orgSlug/pipelines/:slug/builds/:number`. A link
106120
// built from `api_base` or without the `pipelines/` segment is
107121
// unclickable — it lands on raw JSON or a 404.
108122
let watch_url = Some(dashboard_build_url(
109123
&self.app_base,
110124
&self.org,
111-
&req.pipeline_slug,
125+
&pipeline,
112126
build.number,
113127
));
114128
let build_ref = BuildRef {
115129
run_id: uuid::Uuid::new_v4(),
116130
number: Some(build.number),
117131
org: Some(self.org.clone()),
118-
pipeline: req.pipeline_slug.clone(),
132+
pipeline: pipeline.clone(),
119133
};
120134

121135
let (tx, rx) = mpsc::channel(1024);
@@ -151,7 +165,6 @@ impl ExecutionBackend for CloudBackend {
151165
let client = self.client.clone();
152166
let api_base = self.api_base.clone();
153167
let org = self.org.clone();
154-
let pipeline = req.pipeline_slug.clone();
155168
let number = build.number;
156169
let token = cancel.clone();
157170
let started = chrono::Utc::now();

‎crates/hm-exec/src/request.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ pub struct SourceMeta {
6363
pub branch: String,
6464
pub commit: String,
6565
pub message: Option<String>,
66+
/// `owner/repo` from the worktree's git remote, when one exists. `None` for
67+
/// a remoteless worktree; the cloud backend requires it to resolve the
68+
/// pipeline and errors clearly when it is absent.
69+
pub repo_name: Option<String>,
6670
}
6771

6872
/// Per-run execution options threaded through from the CLI flags.

‎crates/hm-exec/tests/backend_contract.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ fn fake_request() -> RunRequest {
130130
branch: "main".into(),
131131
commit: "0".repeat(40),
132132
message: None,
133+
repo_name: None,
133134
},
134135
options: RunOptions {
135136
watch: true,

‎crates/hm/src/commands/run/mod.rs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
123123

124124
// 7. Assemble the run request.
125125
let (branch, commit) = git_metadata(&repo_root, args.branch.clone());
126+
let repo_name = git_remote_repo_name(&repo_root);
126127
let req = hm_exec::RunRequest {
127128
plan,
128129
repo_root,
@@ -132,6 +133,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
132133
branch,
133134
commit,
134135
message: args.message.clone(),
136+
repo_name,
135137
},
136138
options: hm_exec::RunOptions {
137139
no_cache: false,
@@ -202,6 +204,43 @@ fn git_metadata(root: &std::path::Path, branch_override: Option<String>) -> (Str
202204
(branch, commit)
203205
}
204206

207+
/// Parse `owner/repo` from a git remote URL, mirroring the backend's
208+
/// `Harmont.Pipelines.RepoName`: drop scheme/host and a trailing `.git`, then
209+
/// take the last two non-empty path segments. `None` when fewer than two
210+
/// segments remain.
211+
fn parse_repo_name(url: &str) -> Option<String> {
212+
let url = url.trim();
213+
let path = if let Some((_, rest)) = url.split_once("://") {
214+
// scheme://host/owner/repo → strip host
215+
rest.split_once('/').map_or(rest, |(_, p)| p)
216+
} else if url.contains('@') && url.contains(':') {
217+
// scp-style git@host:owner/repo → strip "git@host:"
218+
let after_at = url.split_once('@').map_or(url, |(_, r)| r);
219+
after_at.split_once(':').map_or(after_at, |(_, p)| p)
220+
} else {
221+
url.split_once('/').map_or(url, |(_, p)| p)
222+
};
223+
let path = path.trim_end_matches('/');
224+
let path = path.strip_suffix(".git").unwrap_or(path);
225+
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
226+
if segs.len() < 2 {
227+
return None;
228+
}
229+
Some(segs[segs.len() - 2..].join("/"))
230+
}
231+
232+
/// Best-effort `owner/repo` from the worktree's `origin` remote.
233+
fn git_remote_repo_name(root: &std::path::Path) -> Option<String> {
234+
let out = std::process::Command::new("git")
235+
.arg("-C")
236+
.arg(root)
237+
.args(["config", "--get", "remote.origin.url"])
238+
.output()
239+
.ok()
240+
.filter(|o| o.status.success())?;
241+
parse_repo_name(&String::from_utf8_lossy(&out.stdout))
242+
}
243+
205244
/// Resolve repo root, detect the DSL, select the pipeline slug, and render
206245
/// the v0 IR JSON. Shared by local and cloud runs.
207246
///
@@ -375,6 +414,32 @@ error[backend]: {other}
375414
mod tests {
376415
use super::*;
377416

417+
#[test]
418+
fn parse_repo_name_handles_https_ssh_and_scp() {
419+
assert_eq!(
420+
parse_repo_name("https://github.com/harmont-dev/harmont-cli.git").as_deref(),
421+
Some("harmont-dev/harmont-cli")
422+
);
423+
assert_eq!(
424+
parse_repo_name("git@github.com:harmont-dev/harmont-cli.git").as_deref(),
425+
Some("harmont-dev/harmont-cli")
426+
);
427+
assert_eq!(
428+
parse_repo_name("ssh://git@github.com/harmont-dev/harmont-cli").as_deref(),
429+
Some("harmont-dev/harmont-cli")
430+
);
431+
assert_eq!(
432+
parse_repo_name("https://example.com/a/b/c/repo").as_deref(),
433+
Some("c/repo")
434+
);
435+
}
436+
437+
#[test]
438+
fn parse_repo_name_rejects_unparseable() {
439+
assert_eq!(parse_repo_name(""), None);
440+
assert_eq!(parse_repo_name("not-a-url"), None);
441+
}
442+
378443
#[test]
379444
fn parse_env_splits_pairs() {
380445
let m = parse_env(&["A=1".into(), "B=x=y".into(), "bad".into()]);

0 commit comments

Comments
 (0)