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
30impl HarmontClient {
31    /// Submit a build from a local worktree. Returns the created [`Build`].
32    pub async fn submit_build(&self, b: NewBuild) -> Result<Build> {
33        let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
34        let url = format!(
35            "{}/api/v0/organizations/{}/pipelines/{}/builds",
36            self.base, b.org, b.pipeline
37        );
38        let body = serde_json::json!({
39            "branch": b.branch, "commit": b.commit, "message": b.message,
40            "source": "api", "pipeline_ir": b.pipeline_ir,
41            "source_b64": source_b64, "env": b.env,
42        });
43        let resp = self.http.post(&url).json(&body).send().await?;
44        self.parse_json(resp).await
45    }
46
47    /// Fetch a build by its pipeline-scoped number.
48    pub async fn get_build(&self, org: &str, pipeline: &str, number: i64) -> Result<Build> {
49        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}", self.base);
50        let resp = self.http.get(&url).send().await?;
51        self.parse_json(resp).await
52    }
53
54    /// List the jobs of a build.
55    pub async fn list_jobs(&self, org: &str, pipeline: &str, number: i64)
56        -> Result<Vec<crate::models::Job>>
57    {
58        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs", self.base);
59        let resp = self.http.get(&url).send().await?;
60        #[derive(serde::Deserialize)]
61        struct Wrap { data: Vec<crate::models::Job> }
62        let w: Wrap = self.parse_json(resp).await?;
63        Ok(w.data)
64    }
65
66    /// Cancel a running build.
67    pub async fn cancel_build(&self, org: &str, pipeline: &str, number: i64) -> Result<()> {
68        let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/cancel", self.base);
69        let resp = self.http.put(&url).send().await?;
70        let _: serde_json::Value = self.parse_json(resp).await?;
71        Ok(())
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use base64::Engine;
79    use crate::HarmontClient;
80    use wiremock::matchers::{method, path, body_partial_json};
81    use wiremock::{Mock, MockServer, ResponseTemplate};
82    use serde_json::json;
83
84    #[tokio::test]
85    async fn submit_build_posts_source_b64_and_pipeline_ir() {
86        let server = MockServer::start().await;
87        Mock::given(method("POST"))
88            .and(path("/api/v0/organizations/acme/pipelines/ci/builds"))
89            .and(body_partial_json(json!({
90                "branch": "feat/x", "commit": "abc123", "source": "api",
91                "pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
92                "source_b64": base64::engine::general_purpose::STANDARD.encode(b"fake-tarball"),
93            })))
94            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
95                "number": 42, "state": "scheduled",
96                "branch": "feat/x", "commit": "abc123",
97                "message": null, "source": "api",
98                "created_at": "2026-06-04T00:00:00Z"
99            })))
100            .mount(&server).await;
101
102        let client = HarmontClient::with_base_url("hm_test", server.uri());
103        let req = NewBuild {
104            org: "acme".into(), pipeline: "ci".into(),
105            branch: "feat/x".into(), commit: "abc123".into(),
106            message: None,
107            pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
108            source_tgz: b"fake-tarball".to_vec(),
109            env: Default::default(),
110        };
111        let build = client.submit_build(req).await.expect("submit ok");
112        assert_eq!(build.number, 42);
113        assert_eq!(build.state.to_string(), "scheduled");
114    }
115
116    #[tokio::test]
117    async fn unauthorized_maps_cleanly() {
118        let server = MockServer::start().await;
119        Mock::given(method("GET")).respond_with(ResponseTemplate::new(401))
120            .mount(&server).await;
121        let client = HarmontClient::with_base_url("bad", server.uri());
122        let err = client.get_build("acme", "ci", 1).await.unwrap_err();
123        assert!(matches!(err, crate::HarmontError::Unauthorized));
124    }
125
126    #[tokio::test]
127    async fn rejected_build_maps_to_api_error() {
128        let server = MockServer::start().await;
129        Mock::given(method("POST"))
130            .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
131                "error": {"code": "build_rejected", "message": "bad IR"}
132            }))).mount(&server).await;
133        let client = HarmontClient::with_base_url("hm_test", server.uri());
134        let req = NewBuild { org:"a".into(),pipeline:"c".into(),branch:"b".into(),
135            commit:"x".into(),message:None,pipeline_ir:"{}".into(),
136            source_tgz:vec![],env:Default::default() };
137        let err = client.submit_build(req).await.unwrap_err();
138        assert!(matches!(err, crate::HarmontError::Api { status: 422, .. }));
139    }
140
141    #[tokio::test]
142    async fn list_jobs_unwraps_data() {
143        let server = MockServer::start().await;
144        Mock::given(method("GET"))
145            .and(path("/api/v0/organizations/acme/pipelines/ci/builds/42/jobs"))
146            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
147                "data": [{"id":"00000000-0000-0000-0000-000000000001","state":"running",
148                          "name":"build","depends_on":[],"created_at":"2026-06-04T00:00:00Z",
149                          "soft_failed": false}]
150            }))).mount(&server).await;
151        let client = HarmontClient::with_base_url("hm_test", server.uri());
152        let jobs = client.list_jobs("acme","ci",42).await.expect("ok");
153        assert_eq!(jobs.len(), 1);
154    }
155}