Skip to main content

monochange_hosting/
lib.rs

1#![forbid(clippy::indexing_slicing)]
2
3//! # `monochange_hosting`
4//!
5//! <!-- {=monochangeHostingCrateDocs|trim|linePrefix:"//! ":true} -->
6//! `monochange_hosting` packages the shared git and HTTP plumbing used by hosted source providers.
7//!
8//! Reach for this crate when you are implementing GitHub, Gitea, Forgejo, or GitLab release adapters and want one place for release-body rendering, change-request branch naming, JSON requests, and git branch orchestration.
9//!
10//! ## Why use it?
11//!
12//! - keep provider adapters focused on provider-specific payloads instead of repeated plumbing
13//! - share one markdown rendering path for release bodies and release pull requests
14//! - reuse one set of blocking HTTP helpers with consistent error messages
15//!
16//! ## Best for
17//!
18//! - implementing or testing hosted source adapters
19//! - generating release pull request bodies from prepared manifests
20//! - staging, committing, and pushing release branches through shared wrappers
21//!
22//! ## Public entry points
23//!
24//! - `release_body(source, manifest, target)` resolves the outward release body for a target
25//! - `release_pull_request_body(manifest)` renders the provider change-request body
26//! - `release_pull_request_branch(prefix, command)` normalizes the change-request branch name
27//! - `get_json`, `post_json`, `patch_json`, and `put_json` wrap provider API requests
28//! - `git_checkout_branch`, `git_stage_paths`, `git_commit_paths`, and `git_push_branch` wrap shared git operations
29//! <!-- {/monochangeHostingCrateDocs} -->
30
31use 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
53/// Append release-note entries to a markdown body, normalizing bullet formatting.
54pub 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
74/// Render a fallback release body when no changelog body is available.
75pub 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
104/// Build the provider change-request branch for a release command.
105pub 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
128/// Render the markdown body used for provider release requests.
129pub 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, &section.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
195/// Resolve the provider release body for one outward release target.
196pub 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
216/// Build a blocking HTTP client for provider API calls.
217pub 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
223/// Perform a GET request that treats `404` as `Ok(None)`.
224pub 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
254/// Perform a GET request and deserialize a successful JSON response.
255pub 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
282/// Perform a POST request and deserialize a successful JSON response.
283pub 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
313/// Perform a PUT request and deserialize a successful JSON response.
314pub 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
344/// Perform a PATCH request and deserialize a successful JSON response.
345pub 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
375/// Check out or reset the local release branch used for provider requests.
376pub 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
383/// Stage the provided paths before creating a release commit.
384pub 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
392/// Commit the prepared release changes, tolerating a no-op commit.
393pub 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
402/// Push the release branch to `origin` with `--force-with-lease`.
403pub 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;