1#![forbid(clippy::indexing_slicing)]
2
3use std::path::Path;
32use std::path::PathBuf;
33
34use monochange_core::CommitMessage;
35use monochange_core::MonochangeError;
36use monochange_core::MonochangeResult;
37use monochange_core::ProviderReleaseNotesSource;
38use monochange_core::ReleaseManifest;
39use monochange_core::ReleaseManifestTarget;
40use monochange_core::ReleaseOwnerKind;
41use monochange_core::SourceConfiguration;
42use monochange_core::git::git_checkout_branch_command;
43use monochange_core::git::git_current_branch;
44use monochange_core::git::git_push_branch_command;
45use monochange_core::git::git_stage_paths_command;
46use monochange_core::git::run_command;
47use monochange_core::git::run_git_commit_message;
48use reqwest::blocking::Client;
49use reqwest::header::HeaderMap;
50use serde::Serialize;
51use serde::de::DeserializeOwned;
52
53pub fn push_body_entries(lines: &mut Vec<String>, entries: &[String]) {
55 for (index, entry) in entries.iter().enumerate() {
56 let trimmed = entry.trim();
57
58 if trimmed.contains('\n') {
59 lines.extend(trimmed.lines().map(ToString::to_string));
60 if index + 1 < entries.len() {
61 lines.push(String::new());
62 }
63 continue;
64 }
65
66 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with('#') {
67 lines.push(trimmed.to_string());
68 } else {
69 lines.push(format!("- {trimmed}"));
70 }
71 }
72}
73
74pub fn minimal_release_body(manifest: &ReleaseManifest, target: &ReleaseManifestTarget) -> String {
76 let mut lines = vec![format!("Release target `{}`", target.id), String::new()];
77
78 if !target.members.is_empty() {
79 lines.push(format!("Members: {}", target.members.join(", ")));
80 lines.push(String::new());
81 }
82
83 let reasons = manifest
84 .plan
85 .decisions
86 .iter()
87 .filter(|decision| {
88 target.kind == ReleaseOwnerKind::Package || target.members.contains(&decision.package)
89 })
90 .flat_map(|decision| decision.reasons.iter().cloned())
91 .collect::<Vec<_>>();
92
93 if reasons.is_empty() {
94 lines.push("- prepare release".to_string());
95 } else {
96 for reason in reasons {
97 lines.push(format!("- {reason}"));
98 }
99 }
100
101 lines.join("\n")
102}
103
104pub fn release_pull_request_branch(branch_prefix: &str, command: &str) -> String {
106 let command = command
107 .chars()
108 .map(|character| {
109 if character.is_ascii_alphanumeric() {
110 character.to_ascii_lowercase()
111 } else {
112 '-'
113 }
114 })
115 .collect::<String>()
116 .trim_matches('-')
117 .to_string();
118
119 let command = if command.is_empty() {
120 "release".to_string()
121 } else {
122 command
123 };
124
125 format!("{}/{}", branch_prefix.trim_end_matches('/'), command)
126}
127
128pub fn release_pull_request_body(manifest: &ReleaseManifest) -> String {
130 let mut lines = vec!["## Prepared release".to_string(), String::new()];
131 lines.push(format!("- command: `{}`", manifest.command));
132
133 for target in manifest
134 .release_targets
135 .iter()
136 .filter(|target| target.release)
137 {
138 lines.push(format!(
139 "- {} `{}` -> `{}`",
140 target.kind, target.id, target.tag_name
141 ));
142 }
143
144 if !manifest.release_targets.iter().any(|target| target.release) {
145 lines.push("- no outward release targets".to_string());
146 }
147
148 lines.push(String::new());
149 lines.push("## Release notes".to_string());
150
151 for target in manifest
152 .release_targets
153 .iter()
154 .filter(|target| target.release)
155 {
156 lines.push(String::new());
157 lines.push(format!("### {} {}", target.id, target.version));
158
159 if let Some(changelog) = manifest.changelogs.iter().find(|changelog| {
160 changelog.owner_id == target.id && changelog.owner_kind == target.kind
161 }) {
162 for paragraph in &changelog.notes.summary {
163 lines.push(String::new());
164 lines.push(paragraph.clone());
165 }
166
167 for section in &changelog.notes.sections {
168 if section.entries.is_empty() {
169 continue;
170 }
171 lines.push(String::new());
172 lines.push(format!("### {}", section.title));
173 lines.push(String::new());
174 push_body_entries(&mut lines, §ion.entries);
175 }
176 } else {
177 lines.push(String::new());
178 lines.push(minimal_release_body(manifest, target));
179 }
180 }
181
182 if !manifest.changed_files.is_empty() {
183 lines.push(String::new());
184 lines.push("## Changed files".to_string());
185 lines.push(String::new());
186
187 for path in &manifest.changed_files {
188 lines.push(format!("- {}", path.display()));
189 }
190 }
191
192 lines.join("\n")
193}
194
195pub fn release_body(
197 source: &SourceConfiguration,
198 manifest: &ReleaseManifest,
199 target: &ReleaseManifestTarget,
200) -> Option<String> {
201 match source.releases.source {
202 ProviderReleaseNotesSource::GitHubGenerated => None,
203 ProviderReleaseNotesSource::Monochange => {
204 manifest
205 .changelogs
206 .iter()
207 .find(|changelog| {
208 changelog.owner_id == target.id && changelog.owner_kind == target.kind
209 })
210 .map(|changelog| changelog.rendered.clone())
211 .or_else(|| Some(minimal_release_body(manifest, target)))
212 }
213 }
214}
215
216pub fn build_http_client(provider: &str) -> MonochangeResult<Client> {
218 Client::builder().build().map_err(|error| {
219 MonochangeError::Config(format!("failed to build {provider} HTTP client: {error}"))
220 })
221}
222
223pub fn get_optional_json<T>(
225 client: &Client,
226 headers: &HeaderMap,
227 url: &str,
228 provider: &str,
229) -> MonochangeResult<Option<T>>
230where
231 T: DeserializeOwned,
232{
233 let response = client
234 .get(url)
235 .headers(headers.clone())
236 .send()
237 .map_err(|error| {
238 MonochangeError::Config(format!("{provider} API GET `{url}` failed: {error}"))
239 })?;
240 if response.status().as_u16() == 404 {
241 return Ok(None);
242 }
243 if !response.status().is_success() {
244 return Err(MonochangeError::Config(format!(
245 "{provider} API GET `{url}` failed with status {}",
246 response.status()
247 )));
248 }
249 response.json::<T>().map(Some).map_err(|error| {
250 MonochangeError::Config(format!("{provider} API GET `{url}` failed: {error}"))
251 })
252}
253
254pub fn get_json<T>(
256 client: &Client,
257 headers: &HeaderMap,
258 url: &str,
259 provider: &str,
260) -> MonochangeResult<T>
261where
262 T: DeserializeOwned,
263{
264 let response = client
265 .get(url)
266 .headers(headers.clone())
267 .send()
268 .map_err(|error| {
269 MonochangeError::Config(format!("{provider} API GET `{url}` failed: {error}"))
270 })?;
271 if !response.status().is_success() {
272 return Err(MonochangeError::Config(format!(
273 "{provider} API GET `{url}` failed with status {}",
274 response.status()
275 )));
276 }
277 response.json::<T>().map_err(|error| {
278 MonochangeError::Config(format!("{provider} API GET `{url}` failed: {error}"))
279 })
280}
281
282pub fn post_json<Body, Response>(
284 client: &Client,
285 headers: &HeaderMap,
286 url: &str,
287 body: &Body,
288 provider: &str,
289) -> MonochangeResult<Response>
290where
291 Body: Serialize + ?Sized,
292 Response: DeserializeOwned,
293{
294 let response = client
295 .post(url)
296 .headers(headers.clone())
297 .json(body)
298 .send()
299 .map_err(|error| {
300 MonochangeError::Config(format!("{provider} API POST `{url}` failed: {error}"))
301 })?;
302 if !response.status().is_success() {
303 return Err(MonochangeError::Config(format!(
304 "{provider} API POST `{url}` failed with status {}",
305 response.status()
306 )));
307 }
308 response.json::<Response>().map_err(|error| {
309 MonochangeError::Config(format!("{provider} API POST `{url}` failed: {error}"))
310 })
311}
312
313pub fn put_json<Body, Response>(
315 client: &Client,
316 headers: &HeaderMap,
317 url: &str,
318 body: &Body,
319 provider: &str,
320) -> MonochangeResult<Response>
321where
322 Body: Serialize + ?Sized,
323 Response: DeserializeOwned,
324{
325 let response = client
326 .put(url)
327 .headers(headers.clone())
328 .json(body)
329 .send()
330 .map_err(|error| {
331 MonochangeError::Config(format!("{provider} API PUT `{url}` failed: {error}"))
332 })?;
333 if !response.status().is_success() {
334 return Err(MonochangeError::Config(format!(
335 "{provider} API PUT `{url}` failed with status {}",
336 response.status()
337 )));
338 }
339 response.json::<Response>().map_err(|error| {
340 MonochangeError::Config(format!("{provider} API PUT `{url}` failed: {error}"))
341 })
342}
343
344pub fn patch_json<Body, Response>(
346 client: &Client,
347 headers: &HeaderMap,
348 url: &str,
349 body: &Body,
350 provider: &str,
351) -> MonochangeResult<Response>
352where
353 Body: Serialize + ?Sized,
354 Response: DeserializeOwned,
355{
356 let response = client
357 .patch(url)
358 .headers(headers.clone())
359 .json(body)
360 .send()
361 .map_err(|error| {
362 MonochangeError::Config(format!("{provider} API PATCH `{url}` failed: {error}"))
363 })?;
364 if !response.status().is_success() {
365 return Err(MonochangeError::Config(format!(
366 "{provider} API PATCH `{url}` failed with status {}",
367 response.status()
368 )));
369 }
370 response.json::<Response>().map_err(|error| {
371 MonochangeError::Config(format!("{provider} API PATCH `{url}` failed: {error}"))
372 })
373}
374
375pub fn git_checkout_branch(root: &Path, branch: &str, context: &str) -> MonochangeResult<()> {
377 if matches!(git_current_branch(root).as_deref(), Ok(current) if current == branch) {
378 return Ok(());
379 }
380 run_command(git_checkout_branch_command(root, branch), context)
381}
382
383pub fn git_stage_paths(
385 root: &Path,
386 tracked_paths: &[PathBuf],
387 context: &str,
388) -> MonochangeResult<()> {
389 run_command(git_stage_paths_command(root, tracked_paths), context)
390}
391
392pub fn git_commit_paths(
394 root: &Path,
395 message: &CommitMessage,
396 context: &str,
397 no_verify: bool,
398) -> MonochangeResult<()> {
399 run_git_commit_message(root, message, context, no_verify)
400}
401
402pub fn git_push_branch(
404 root: &Path,
405 branch: &str,
406 context: &str,
407 no_verify: bool,
408) -> MonochangeResult<()> {
409 run_command(git_push_branch_command(root, branch, no_verify), context)
410}
411
412#[cfg(test)]
413#[path = "__tests__/lib_tests.rs"]
414mod tests;