pipedash_plugin_api/
utils.rs1use std::collections::HashMap;
2use std::future::Future;
3use std::sync::OnceLock;
4use std::time::Duration;
5
6use chrono::{
7 DateTime,
8 Utc,
9};
10use futures::StreamExt;
11use reqwest::Url;
12
13use crate::config::comma_separated;
14use crate::http::map_send_error;
15use crate::{
16 PipelineStatus,
17 PluginError,
18 PluginResult,
19};
20
21pub struct RetryPolicy {
22 pub max_retries: usize,
23 pub initial_delay: Duration,
24 pub exponential_backoff: bool,
25}
26
27impl Default for RetryPolicy {
28 fn default() -> Self {
29 Self {
30 max_retries: 3,
31 initial_delay: Duration::from_millis(100),
32 exponential_backoff: true,
33 }
34 }
35}
36
37impl RetryPolicy {
38 pub fn new(max_retries: usize, initial_delay: Duration, exponential_backoff: bool) -> Self {
39 Self {
40 max_retries,
41 initial_delay,
42 exponential_backoff,
43 }
44 }
45
46 pub async fn retry<F, Fut, T>(&self, operation: F) -> PluginResult<T>
47 where
48 F: Fn() -> Fut,
49 Fut: Future<Output = PluginResult<T>>,
50 {
51 let mut delay = self.initial_delay;
52 let mut attempt = 1;
53
54 loop {
55 match operation().await {
56 Ok(result) => return Ok(result),
57 Err(e) if attempt < self.max_retries && Self::should_retry(&e, delay) => {
58 tokio::time::sleep(delay).await;
59 delay = self.next_delay(delay);
60 attempt += 1;
61 }
62 Err(e) => return Err(e),
63 }
64 }
65 }
66
67 pub async fn retry_unsent<F, Fut>(&self, url: &Url, send: F) -> PluginResult<reqwest::Response>
68 where
69 F: Fn() -> Fut,
70 Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
71 {
72 let mut delay = self.initial_delay;
73 let mut attempt = 1;
74
75 loop {
76 match send().await {
77 Ok(response) => return Ok(response),
78 Err(e) if attempt < self.max_retries && e.is_connect() && !e.is_timeout() => {
79 tokio::time::sleep(delay).await;
80 delay = self.next_delay(delay);
81 attempt += 1;
82 }
83 Err(e) => return Err(map_send_error(e, url)),
84 }
85 }
86 }
87
88 fn should_retry(error: &PluginError, delay: Duration) -> bool {
89 error.is_transient() && error.retry_after().is_none_or(|hint| hint <= delay)
90 }
91
92 fn next_delay(&self, delay: Duration) -> Duration {
93 if self.exponential_backoff {
94 delay.saturating_mul(2)
95 } else {
96 delay
97 }
98 }
99}
100
101#[derive(Debug)]
102pub struct Cached<T>(OnceLock<T>);
103
104impl<T> Default for Cached<T> {
105 fn default() -> Self {
106 Self(OnceLock::new())
107 }
108}
109
110impl<T: Clone> Cached<T> {
111 pub async fn get_or_fetch<F, Fut>(&self, fetch: F) -> PluginResult<T>
112 where
113 F: FnOnce() -> Fut,
114 Fut: Future<Output = PluginResult<T>>,
115 {
116 if let Some(value) = self.0.get() {
117 return Ok(value.clone());
118 }
119 let value = fetch().await?;
120 let _ = self.0.set(value.clone());
121 Ok(value)
122 }
123}
124
125pub async fn retain_with_ci<F, Fut>(ids: Vec<String>, has_ci: F) -> Vec<String>
126where
127 F: Fn(String) -> Fut,
128 Fut: Future<Output = PluginResult<bool>>,
129{
130 futures::stream::iter(ids)
131 .map(|id| {
132 let check = has_ci(id.clone());
133 async move { check.await.unwrap_or(true).then_some(id) }
134 })
135 .buffered(8)
136 .filter_map(std::future::ready)
137 .collect()
138 .await
139}
140
141pub fn status_from_conclusion(
142 status: &str, conclusion: Option<&str>, successful: &[&str], cancelled: &[&str],
143) -> PipelineStatus {
144 match (status, conclusion) {
145 ("completed", Some(c)) if successful.contains(&c) => PipelineStatus::Success,
146 ("completed", Some(c)) if cancelled.contains(&c) => PipelineStatus::Cancelled,
147 ("completed", Some("skipped")) => PipelineStatus::Skipped,
148 ("completed", _) => PipelineStatus::Failed,
149 ("in_progress", _) | ("queued", _) => PipelineStatus::Running,
150 _ => PipelineStatus::Pending,
151 }
152}
153
154pub fn repository_name(repo_url: &str) -> String {
155 let cleaned = repo_url
156 .trim_end_matches('/')
157 .trim_end_matches(".git")
158 .trim_end_matches('/');
159
160 if !cleaned.contains("://") {
161 if let Some((_, path)) = cleaned.rsplit_once(':') {
162 if path.contains('/') {
163 return path.to_string();
164 }
165 }
166 }
167
168 let mut segments = cleaned.rsplit('/');
169 match (segments.next(), segments.next()) {
170 (Some(repo), Some(org)) => format!("{org}/{repo}"),
171 _ => repo_url.to_string(),
172 }
173}
174
175pub fn parse_timestamp(value: &str) -> Option<DateTime<Utc>> {
176 DateTime::parse_from_rfc3339(value)
177 .ok()
178 .map(|parsed| parsed.with_timezone(&Utc))
179}
180
181pub fn duration_seconds(
182 started: Option<DateTime<Utc>>, finished: Option<DateTime<Utc>>,
183) -> Option<i64> {
184 Some((finished? - started?).num_seconds())
185}
186
187pub fn collect_partial<T>(
188 results: impl IntoIterator<Item = PluginResult<T>>, mut skipped: impl FnMut(&PluginError),
189) -> PluginResult<Vec<T>> {
190 let mut items = Vec::new();
191 for result in results {
192 match result {
193 Ok(item) => items.push(item),
194 Err(error) if error.is_transient() => return Err(error),
195 Err(error) => skipped(&error),
196 }
197 }
198 Ok(items)
199}
200
201pub fn selected_items(config: &HashMap<String, String>) -> impl Iterator<Item = &str> {
202 config
203 .get("selected_items")
204 .into_iter()
205 .flat_map(|items| comma_separated(items))
206}
207
208pub const fn provider_type(package_name: &'static str) -> &'static str {
209 const PREFIX: &[u8] = b"pipedash-plugin-";
210 let name = package_name.as_bytes();
211 if name.len() < PREFIX.len() {
212 return package_name;
213 }
214 let (head, rest) = name.split_at(PREFIX.len());
215 let mut index = 0;
216 while index < PREFIX.len() {
217 if head[index] != PREFIX[index] {
218 return package_name;
219 }
220 index += 1;
221 }
222 match core::str::from_utf8(rest) {
223 Ok(stripped) => stripped,
224 Err(_) => package_name,
225 }
226}
227
228pub fn svg_data_url(svg: &str) -> String {
229 const PREFIX: &str = "data:image/svg+xml,";
230 const HEX: &[u8; 16] = b"0123456789ABCDEF";
231 let mut url = String::with_capacity(PREFIX.len() + svg.len());
232 url.push_str(PREFIX);
233 for byte in svg.bytes() {
234 if byte.is_ascii_alphanumeric() || b"-._~/:;,=!*'()".contains(&byte) {
235 url.push(char::from(byte));
236 } else {
237 url.push('%');
238 url.push(char::from(HEX[usize::from(byte >> 4)]));
239 url.push(char::from(HEX[usize::from(byte & 0x0F)]));
240 }
241 }
242 url
243}
244
245#[cfg(test)]
246mod tests;