Skip to main content

harmont_cloud/
builds.rs

1//! Build submission and status.
2
3use std::collections::HashMap;
4use base64::Engine;
5use crate::{HarmontClient, Result};
6use crate::models::Build;
7
8/// A new build of a *local* worktree: the v0 IR is pre-rendered and the
9/// source is uploaded inline as a gzipped tarball (base64 `source_b64`).
10#[derive(Debug, Clone)]
11pub struct NewBuild {
12    /// Organization slug.
13    pub org: String,
14    /// Pipeline slug.
15    pub pipeline: String,
16    /// Source branch recorded on the build.
17    pub branch: String,
18    /// Source commit SHA recorded on the build.
19    pub commit: String,
20    /// Optional build/commit message.
21    pub message: Option<String>,
22    /// Pre-rendered v0 IR JSON (`{"version":"0","steps":[...]}`).
23    pub pipeline_ir: String,
24    /// Raw gzipped tar bytes of the worktree (will be base64-encoded).
25    pub source_tgz: Vec<u8>,
26    /// Build-level environment variables.
27    pub env: HashMap<String, String>,
28}
29
30/// A new build addressed by the repo-natural identity `(repo_name, source_slug)`
31/// rather than the org-global pipeline slug — the `hm run` path. A repo-local
32/// client knows its git remote and its in-repo pipeline name, but not the
33/// namespaced slug the server assigns on discovery.
34#[derive(Debug, Clone)]
35pub struct NewRepoBuild {
36    /// Organization slug.
37    pub org: String,
38    /// Repository `owner/repo`, from the worktree's git remote.
39    pub repo_name: String,
40    /// In-repo pipeline name — the `@hm.pipeline("…")` slug.
41    pub source_slug: String,
42    /// Source branch recorded on the build.
43    pub branch: String,
44    /// Source commit SHA recorded on the build.
45    pub commit: String,
46    /// Optional build/commit message.
47    pub message: Option<String>,
48    /// Pre-rendered v0 IR JSON.
49    pub pipeline_ir: String,
50    /// Raw gzipped tar bytes of the worktree (will be base64-encoded).
51    pub source_tgz: Vec<u8>,
52    /// Build-level environment variables.
53    pub env: HashMap<String, String>,
54}
55
56impl HarmontClient {
57    /// Submit a build from a local worktree. Returns the created [`Build`].
58    pub async fn submit_build(&self, b: NewBuild) -> Result<Build> {
59        let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
60        let url = format!(
61            "{}/api/v0/organizations/{}/pipelines/{}/builds",
62            self.base, b.org, b.pipeline
63        );
64        let body = serde_json::json!({
65            "branch": b.branch, "commit": b.commit, "message": b.message,
66            "source": "api", "pipeline_ir": b.pipeline_ir,
67            "source_b64": source_b64, "env": b.env,
68        });
69        let resp = self.http.post(&url).json(&body).send().await?;
70        self.parse_json(resp).await
71    }
72
73    /// Submit a build addressed by `(repo_name, source_slug)` — the `hm run`
74    /// path. Returns the created [`Build`], whose `pipeline_slug` is the
75    /// resolved global slug (use it to watch/cancel). Errors carry the server's
76    /// structured `code`/`message`, including a missing-pipeline 404.
77    pub async fn submit_repo_build(&self, b: NewRepoBuild) -> Result<Build> {
78        let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
79        let url = format!("{}/api/v0/organizations/{}/builds", self.base, b.org);
80        let body = serde_json::json!({
81            "repo_name": b.repo_name, "source_slug": b.source_slug,
82            "branch": b.branch, "commit": b.commit, "message": b.message,
83            "source": "api", "pipeline_ir": b.pipeline_ir,
84            "source_b64": source_b64, "env": b.env,
85        });
86        let resp = self.http.post(&url).json(&body).send().await?;
87        self.parse_json_structured(resp).await
88    }
89
90    /// Fetch a build by its pipeline-scoped number.
91    pub async fn get_build(&self, org: &str, pipeline: &str, number: i64) -> Result<Build> {
92        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}", self.base);
93        let resp = self.http.get(&url).send().await?;
94        self.parse_json(resp).await
95    }
96
97    /// List the jobs of a build.
98    pub async fn list_jobs(&self, org: &str, pipeline: &str, number: i64)
99        -> Result<Vec<crate::models::Job>>
100    {
101        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs", self.base);
102        let resp = self.http.get(&url).send().await?;
103        #[derive(serde::Deserialize)]
104        struct Wrap { data: Vec<crate::models::Job> }
105        let w: Wrap = self.parse_json(resp).await?;
106        Ok(w.data)
107    }
108
109    /// Cancel a running build.
110    pub async fn cancel_build(&self, org: &str, pipeline: &str, number: i64) -> Result<()> {
111        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/cancel", self.base);
112        let resp = self.http.put(&url).send().await?;
113        let _: serde_json::Value = self.parse_json(resp).await?;
114        Ok(())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use base64::Engine;
122    use crate::HarmontClient;
123    use wiremock::matchers::{method, path, body_partial_json};
124    use wiremock::{Mock, MockServer, ResponseTemplate};
125    use serde_json::json;
126
127    #[tokio::test]
128    async fn submit_build_posts_source_b64_and_pipeline_ir() {
129        let server = MockServer::start().await;
130        Mock::given(method("POST"))
131            .and(path("/api/v0/organizations/acme/pipelines/ci/builds"))
132            .and(body_partial_json(json!({
133                "branch": "feat/x", "commit": "abc123", "source": "api",
134                "pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
135                "source_b64": base64::engine::general_purpose::STANDARD.encode(b"fake-tarball"),
136            })))
137            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
138                "number": 42, "state": "scheduled", "pipeline_slug": "ci",
139                "branch": "feat/x", "commit": "abc123",
140                "message": null, "source": "api",
141                "created_at": "2026-06-04T00:00:00Z"
142            })))
143            .mount(&server).await;
144
145        let client = HarmontClient::with_base_url("hm_test", server.uri());
146        let req = NewBuild {
147            org: "acme".into(), pipeline: "ci".into(),
148            branch: "feat/x".into(), commit: "abc123".into(),
149            message: None,
150            pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
151            source_tgz: b"fake-tarball".to_vec(),
152            env: Default::default(),
153        };
154        let build = client.submit_build(req).await.expect("submit ok");
155        assert_eq!(build.number, 42);
156        assert_eq!(build.state.to_string(), "scheduled");
157    }
158
159    #[tokio::test]
160    async fn unauthorized_maps_cleanly() {
161        let server = MockServer::start().await;
162        Mock::given(method("GET")).respond_with(ResponseTemplate::new(401))
163            .mount(&server).await;
164        let client = HarmontClient::with_base_url("bad", server.uri());
165        let err = client.get_build("acme", "ci", 1).await.unwrap_err();
166        assert!(matches!(err, crate::HarmontError::Unauthorized));
167    }
168
169    #[tokio::test]
170    async fn rejected_build_maps_to_api_error() {
171        let server = MockServer::start().await;
172        Mock::given(method("POST"))
173            .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
174                "error": {"code": "build_rejected", "message": "bad IR"}
175            }))).mount(&server).await;
176        let client = HarmontClient::with_base_url("hm_test", server.uri());
177        let req = NewBuild { org:"a".into(),pipeline:"c".into(),branch:"b".into(),
178            commit:"x".into(),message:None,pipeline_ir:"{}".into(),
179            source_tgz:vec![],env:Default::default() };
180        let err = client.submit_build(req).await.unwrap_err();
181        assert!(matches!(err, crate::HarmontError::Api { status: 422, .. }));
182    }
183
184    #[tokio::test]
185    async fn submit_repo_build_posts_to_org_builds_with_repo_and_source() {
186        let server = MockServer::start().await;
187        Mock::given(method("POST"))
188            .and(path("/api/v0/organizations/acme/builds"))
189            .and(body_partial_json(json!({
190                "repo_name": "harmont-dev/acme", "source_slug": "ci",
191                "branch": "main", "commit": "abc123", "source": "api",
192                "pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
193            })))
194            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
195                "number": 7, "state": "scheduled", "pipeline_slug": "harmont-dev-acme-ci",
196                "branch": "main", "commit": "abc123", "message": null, "source": "api",
197                "created_at": "2026-06-10T00:00:00Z"
198            })))
199            .mount(&server).await;
200
201        let client = HarmontClient::with_base_url("hm_test", server.uri());
202        let req = NewRepoBuild {
203            org: "acme".into(), repo_name: "harmont-dev/acme".into(), source_slug: "ci".into(),
204            branch: "main".into(), commit: "abc123".into(), message: None,
205            pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
206            source_tgz: b"fake-tarball".to_vec(), env: Default::default(),
207        };
208        let build = client.submit_repo_build(req).await.expect("submit ok");
209        assert_eq!(build.number, 7);
210        assert_eq!(build.pipeline_slug, "harmont-dev-acme-ci");
211    }
212
213    #[tokio::test]
214    async fn submit_repo_build_404_surfaces_structured_code() {
215        let server = MockServer::start().await;
216        Mock::given(method("POST"))
217            .respond_with(ResponseTemplate::new(404).set_body_json(json!({
218                "error": {"code": "pipeline_not_found", "message": "No pipeline `ci` found …"}
219            }))).mount(&server).await;
220        let client = HarmontClient::with_base_url("hm_test", server.uri());
221        let req = NewRepoBuild {
222            org: "a".into(), repo_name: "o/r".into(), source_slug: "ci".into(),
223            branch: "b".into(), commit: "x".into(), message: None,
224            pipeline_ir: "{}".into(), source_tgz: vec![], env: Default::default(),
225        };
226        let err = client.submit_repo_build(req).await.unwrap_err();
227        assert!(matches!(
228            err,
229            crate::HarmontError::Api { status: 404, ref code, .. } if code == "pipeline_not_found"
230        ));
231    }
232
233    #[tokio::test]
234    async fn list_jobs_unwraps_data() {
235        let server = MockServer::start().await;
236        Mock::given(method("GET"))
237            .and(path("/api/v0/organizations/acme/pipelines/ci/builds/42/jobs"))
238            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
239                "data": [{"id":"00000000-0000-0000-0000-000000000001","state":"running",
240                          "name":"build","depends_on":[],"created_at":"2026-06-04T00:00:00Z",
241                          "soft_failed": false}]
242            }))).mount(&server).await;
243        let client = HarmontClient::with_base_url("hm_test", server.uri());
244        let jobs = client.list_jobs("acme","ci",42).await.expect("ok");
245        assert_eq!(jobs.len(), 1);
246    }
247}