1use std::collections::HashMap;
4use base64::Engine;
5use crate::{HarmontClient, Result};
6use crate::models::Build;
7
8#[derive(Debug, Clone)]
11pub struct NewBuild {
12 pub org: String,
14 pub pipeline: String,
16 pub branch: String,
18 pub commit: String,
20 pub message: Option<String>,
22 pub pipeline_ir: String,
24 pub source_tgz: Vec<u8>,
26 pub env: HashMap<String, String>,
28}
29
30#[derive(Debug, Clone)]
35pub struct NewRepoBuild {
36 pub org: String,
38 pub repo_name: String,
40 pub source_slug: String,
42 pub branch: String,
44 pub commit: String,
46 pub message: Option<String>,
48 pub pipeline_ir: String,
50 pub source_tgz: Vec<u8>,
52 pub env: HashMap<String, String>,
54}
55
56impl HarmontClient {
57 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 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 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 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 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}