| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | | use crate::auth::roles; |
| 11 | | use crate::db::{self, accounts::Account, repos::RepoRow, Pool}; |
| 12 | | use crate::repos::RepoName; |
| 13 | | use crate::Shared; |
| 14 | | use oxy_core::hash::ChangeHash; |
| 15 | | use serde::Serialize; |
| 16 | |
|
| 17 | |
|
| 18 | | pub const LEARN_DAYS: i64 = 90; |
| 19 | |
|
| 20 | |
|
| 21 | |
|
| 22 | |
|
| 23 | 327× | pub async fn target(pool: &Pool, row: &RepoRow) -> String { |
| 24 | 327× | let since = db::now_ms() - LEARN_DAYS * 24 * 3600 * 1000; |
| 25 | 327× | let learned: Option<String> = sqlx::query_scalar( |
| 26 | 327× | "SELECT target FROM proposals WHERE repo_id = $1 AND state = 'landed' AND landed_ms >= $2 |
| 27 | 327× | GROUP BY target ORDER BY COUNT(*) DESC, MAX(landed_ms) DESC LIMIT 1", |
| 28 | 327× | ) |
| 29 | 327× | .bind(row.id) |
| 30 | 327× | .bind(since) |
| 31 | 327× | .fetch_optional(pool) |
| 32 | 327× | .await |
| 33 | 327× | .ok() |
| 34 | 327× | .flatten(); |
| 35 | 327× | learned.filter(|t| !t.is_empty()).unwrap_or_else(|| row.default_bookmark.clone()) |
| 36 | 327× | } |
| 37 | |
|
| 38 | |
|
| 39 | |
|
| 40 | 280× | pub async fn followed_bookmarks(pool: &Pool, row: &RepoRow) -> Vec<String> { |
| 41 | 280× | let mut out = vec![row.default_bookmark.clone()]; |
| 42 | 280× | let t = target(pool, row).await; |
| 43 | 280× | if !out.contains(&t) { |
| 44 | 0× | out.push(t); |
| 45 | 280× | } |
| 46 | 280× | out |
| 47 | 280× | } |
| 48 | |
|
| 49 | |
|
| 50 | 225× | pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) { |
| 51 | | |
| 52 | | |
| 53 | 225× | let repo = format!("{}/{}", row.owner, row.name); |
| 54 | 226× | for attempt in 1..=2 { |
| 55 | 226× | match try_record(app, row, bookmark, heads).await { |
| 56 | | Ok(()) => { |
| 57 | 223× | if attempt > 1 { |
| 58 | 1× | tracing::info!("diffusion: indexed {repo} {bookmark} on the second try"); |
| 59 | 222× | } |
| 60 | 223× | return; |
| 61 | | } |
| 62 | 3× | Err(why) if attempt == 1 => { |
| 63 | 3× | tracing::warn!("diffusion: indexing {repo} {bookmark} failed ({why}); trying again"); |
| 64 | 3× | tokio::time::sleep(RETRY_AFTER).await; |
| 65 | | } |
| 66 | 0× | Err(why) => { |
| 67 | 0× | FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 68 | 0× | tracing::error!("diffusion: indexing {repo} {bookmark} failed again ({why}); its pins stay as they were"); |
| 69 | | } |
| 70 | | } |
| 71 | | } |
| 72 | 223× | } |
| 73 | |
|
| 74 | |
|
| 75 | | const RETRY_AFTER: std::time::Duration = std::time::Duration::from_millis(250); |
| 76 | |
|
| 77 | |
|
| 78 | | pub static FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| 79 | |
|
| 80 | |
|
| 81 | | static FORCED: std::sync::Mutex<Option<std::collections::HashMap<String, u32>>> = std::sync::Mutex::new(None); |
| 82 | |
|
| 83 | |
|
| 84 | |
|
| 85 | | #[doc(hidden)] |
| 86 | 1× | pub fn fail_next_records(app: &Shared, repo_id: i64, n: u32) { |
| 87 | 1× | FORCED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id), n); |
| 88 | 1× | } |
| 89 | |
|
| 90 | |
|
| 91 | | #[doc(hidden)] |
| 92 | 1× | pub fn forced_left(app: &Shared, repo_id: i64) -> u32 { |
| 93 | 1× | FORCED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().and_then(|m| m.get(&key(app, repo_id)).copied()).unwrap_or(0) |
| 94 | 1× | } |
| 95 | |
|
| 96 | 224× | fn forced(app: &Shared, repo_id: i64) -> bool { |
| 97 | 224× | let mut forced = FORCED.lock().unwrap_or_else(|p| p.into_inner()); |
| 98 | 224× | match forced.as_mut().and_then(|m| m.get_mut(&key(app, repo_id))) { |
| 99 | 3× | Some(n) if *n > 0 => { |
| 100 | 1× | *n -= 1; |
| 101 | 1× | true |
| 102 | | } |
| 103 | 223× | _ => false, |
| 104 | | } |
| 105 | 224× | } |
| 106 | |
|
| 107 | |
|
| 108 | |
|
| 109 | |
|
| 110 | 226× | async fn try_record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Result<(), String> { |
| 111 | 226× | let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(()) }; |
| 112 | 226× | let pins = if heads.is_empty() { |
| 113 | 0× | Vec::new() |
| 114 | | } else { |
| 115 | 226× | crate::flow::pins_in(app, name, heads.to_vec()).await.map_err(|e| format!("reading the pins: {e}"))? |
| 116 | | }; |
| 117 | 224× | let now = db::now_ms(); |
| 118 | | |
| 119 | 224× | let mut upstreams = Vec::new(); |
| 120 | 224× | for (_, id, _) in &pins { |
| 121 | 13× | upstreams.push(repo_by_oxy_id(app, &hex::encode(id)).await.map(|r| r.id)); |
| 122 | | } |
| 123 | 224× | if forced(app, row.id) { |
| 124 | 1× | return Err("begin: forced for a test".into()); |
| 125 | 223× | } |
| 126 | 223× | let mut tx = app.db.begin().await.map_err(|e| format!("begin: {e}"))?; |
| 127 | 223× | sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = $1 AND bookmark = $2") |
| 128 | 223× | .bind(row.id) |
| 129 | 223× | .bind(bookmark) |
| 130 | 223× | .execute(&mut *tx) |
| 131 | 223× | .await |
| 132 | 223× | .map_err(|e| format!("delete: {e}"))?; |
| 133 | 223× | for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) { |
| 134 | 23× | let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect(); |
| 135 | 12× | sqlx::query( |
| 136 | 12× | "INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms, upstream_repo_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", |
| 137 | 12× | ) |
| 138 | 12× | .bind(row.id) |
| 139 | 12× | .bind(bookmark) |
| 140 | 12× | .bind(path) |
| 141 | 12× | .bind(hex::encode(id)) |
| 142 | 12× | .bind(heads.join("+")) |
| 143 | 12× | .bind(now) |
| 144 | 12× | .bind(upstream_row) |
| 145 | 12× | .execute(&mut *tx) |
| 146 | 12× | .await |
| 147 | 12× | .map_err(|e| format!("insert {path}: {e}"))?; |
| 148 | | } |
| 149 | 223× | sqlx::query("INSERT INTO diffusion_indexed (consumer_id, bookmark, heads, updated_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (consumer_id, bookmark) DO UPDATE SET heads = excluded.heads, updated_ms = excluded.updated_ms") |
| 150 | 223× | .bind(row.id) |
| 151 | 223× | .bind(bookmark) |
| 152 | 223× | .bind(heads_key(heads)) |
| 153 | 223× | .bind(now) |
| 154 | 223× | .execute(&mut *tx) |
| 155 | 223× | .await |
| 156 | 223× | .map_err(|e| format!("noting the state: {e}"))?; |
| 157 | 223× | tx.commit().await.map_err(|e| format!("commit: {e}"))?; |
| 158 | 223× | for u in upstreams.iter().flatten() { |
| 159 | 12× | mark(app, row.id); |
| 160 | 12× | mark(app, *u); |
| 161 | 12× | } |
| 162 | 223× | Ok(()) |
| 163 | 226× | } |
| 164 | |
|
| 165 | |
|
| 166 | 272× | pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) { |
| 167 | 272× | let followed = followed_bookmarks(&app.db, row).await; |
| 168 | 272× | for (bookmark, _, heads) in moved { |
| 169 | 272× | if followed.contains(bookmark) { |
| 170 | 224× | record(app, row, bookmark, heads).await; |
| 171 | 48× | } |
| 172 | | } |
| 173 | 270× | } |
| 174 | |
|
| 175 | | static INVOLVED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None); |
| 176 | |
|
| 177 | 37× | fn key(app: &Shared, repo_id: i64) -> String { |
| 178 | 37× | format!("{}#{repo_id}", app.repos.root().display()) |
| 179 | 37× | } |
| 180 | |
|
| 181 | 24× | fn mark(app: &Shared, repo_id: i64) { |
| 182 | 24× | INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id)); |
| 183 | 24× | } |
| 184 | |
|
| 185 | |
|
| 186 | |
|
| 187 | 540× | pub fn involved(app: &Shared, repo_id: i64) -> bool { |
| 188 | 540× | INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id))) |
| 189 | 540× | } |
| 190 | |
|
| 191 | |
|
| 192 | |
|
| 193 | 205× | pub async fn backfill(app: &Shared) { |
| 194 | 205× | let Ok(rows) = db::repos::list(&app.db).await else { return }; |
| 195 | 204× | for row in rows { |
| 196 | 1× | let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 197 | 1× | for bookmark in followed_bookmarks(&app.db, &row).await { |
| 198 | 1× | let b = bookmark.clone(); |
| 199 | 1× | let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await; |
| 200 | 1× | if let Ok(heads) = heads { |
| 201 | 0× | record(app, &row, &bookmark, &heads).await; |
| 202 | 1× | } |
| 203 | | } |
| 204 | | } |
| 205 | 204× | } |
| 206 | |
|
| 207 | |
|
| 208 | 276× | fn heads_key(heads: &[ChangeHash]) -> String { |
| 209 | 516× | let mut h: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
| 210 | 276× | h.sort(); |
| 211 | 276× | h.join("+") |
| 212 | 276× | } |
| 213 | |
|
| 214 | |
|
| 215 | | pub const RECONCILE_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60); |
| 216 | |
|
| 217 | |
|
| 218 | | #[derive(Debug, Default, PartialEq, Eq)] |
| 219 | | pub struct Repairs { |
| 220 | | |
| 221 | | pub reindexed: usize, |
| 222 | | |
| 223 | | |
| 224 | | pub caught_up: usize, |
| 225 | | } |
| 226 | |
|
| 227 | |
|
| 228 | |
|
| 229 | 205× | pub async fn reconciler(app: Shared) { |
| 230 | | loop { |
| 231 | 205× | tokio::time::sleep(RECONCILE_EVERY).await; |
| 232 | 0× | let r = reconcile(&app).await; |
| 233 | 0× | if r != Repairs::default() { |
| 234 | 0× | tracing::warn!("diffusion: reconciled: {} bookmarks re-indexed, {} consumers caught up", r.reindexed, r.caught_up); |
| 235 | 0× | } |
| 236 | | } |
| 237 | | } |
| 238 | |
|
| 239 | |
|
| 240 | |
|
| 241 | |
|
| 242 | 3× | pub async fn reconcile(app: &Shared) -> Repairs { |
| 243 | 3× | let mut repairs = Repairs::default(); |
| 244 | 3× | let Ok(rows) = db::repos::list(&app.db).await else { return repairs }; |
| 245 | 7× | for row in &rows { |
| 246 | 7× | let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 247 | 7× | for bookmark in followed_bookmarks(&app.db, row).await { |
| 248 | 7× | let b = bookmark.clone(); |
| 249 | 7× | let Ok(heads) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await else { continue }; |
| 250 | 7× | let indexed: Option<String> = |
| 251 | 7× | sqlx::query_scalar("SELECT heads FROM diffusion_indexed WHERE consumer_id = $1 AND bookmark = $2") |
| 252 | 7× | .bind(row.id) |
| 253 | 7× | .bind(&bookmark) |
| 254 | 7× | .fetch_optional(&app.db) |
| 255 | 7× | .await |
| 256 | 7× | .ok() |
| 257 | 7× | .flatten(); |
| 258 | 7× | if indexed.as_deref() != Some(heads_key(&heads).as_str()) { |
| 259 | 1× | tracing::info!("diffusion: {}/{} {bookmark}: the index was not at the bookmark; re-indexing", row.owner, row.name); |
| 260 | 1× | record(app, row, &bookmark, &heads).await; |
| 261 | 1× | repairs.reindexed += 1; |
| 262 | 6× | } |
| 263 | | } |
| 264 | | } |
| 265 | | |
| 266 | 3× | let pins: Vec<(i64, String, String, String)> = |
| 267 | 3× | sqlx::query_as("SELECT consumer_id, path, upstream, heads FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL") |
| 268 | 3× | .fetch_all(&app.db) |
| 269 | 3× | .await |
| 270 | 3× | .unwrap_or_default(); |
| 271 | 3× | let mut caught = std::collections::HashSet::new(); |
| 272 | 4× | for (consumer_id, path, upstream, pinned) in pins { |
| 273 | 4× | if caught.contains(&consumer_id) { |
| 274 | 0× | continue; |
| 275 | 4× | } |
| 276 | 4× | let Some(up) = repo_by_oxy_id(app, &upstream).await else { continue }; |
| 277 | 4× | let Some(green) = last_green(app, &up).await else { continue }; |
| 278 | 4× | if heads_key(&green) == heads_key(&pinned.split('+').filter_map(ChangeHash::from_hex).collect::<Vec<_>>()) { |
| 279 | 2× | continue; |
| 280 | 2× | } |
| 281 | 2× | let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue }; |
| 282 | 2× | let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue }; |
| 283 | 2× | let learned = target(&app.db, &consumer).await; |
| 284 | 2× | let modules = oxymodules(app, &cname, &learned).await; |
| 285 | 2× | if rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path).follow == Follow::Off { |
| 286 | 0× | continue; |
| 287 | 2× | } |
| 288 | | |
| 289 | | |
| 290 | | |
| 291 | 2× | let bump_bm = bump_bookmark(&path); |
| 292 | 2× | let newest = || async { |
| 293 | 2× | db::proposals::list(&app.db, consumer_id, &[]) |
| 294 | 2× | .await |
| 295 | 2× | .unwrap_or_default() |
| 296 | 2× | .into_iter() |
| 297 | 4× | .filter(|p| p.source_bookmark == bump_bm) |
| 298 | 2× | .max_by_key(|p| p.number) |
| 299 | 2× | .map(|p| p.state) |
| 300 | 4× | }; |
| 301 | 2× | let has_bump = || async { matches!(newest().await.as_deref(), Some("open" | "draft")) }; |
| 302 | 2× | if matches!(newest().await.as_deref(), Some("open" | "draft" | "closed")) { |
| 303 | 2× | continue; |
| 304 | 0× | } |
| 305 | 0× | caught.insert(consumer_id); |
| 306 | 0× | catch_up(app, &consumer).await; |
| 307 | 0× | if has_bump().await { |
| 308 | 0× | tracing::info!( |
| 309 | | "diffusion: {}/{} {path} was behind {}/{} with no bump; bumped", |
| 310 | | consumer.owner, |
| 311 | | consumer.name, |
| 312 | | up.owner, |
| 313 | | up.name |
| 314 | | ); |
| 315 | 0× | repairs.caught_up += 1; |
| 316 | 0× | } |
| 317 | | } |
| 318 | 3× | repairs |
| 319 | 3× | } |
| 320 | |
|
| 321 | |
|
| 322 | |
|
| 323 | 109× | fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> { |
| 324 | 151× | Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default()) |
| 325 | 109× | } |
| 326 | |
|
| 327 | |
|
| 328 | | #[derive(Clone, Debug, Serialize)] |
| 329 | | pub struct Consumer { |
| 330 | | pub owner: String, |
| 331 | | pub name: String, |
| 332 | | pub bookmark: String, |
| 333 | | pub path: String, |
| 334 | | |
| 335 | | pub pinned: Vec<String>, |
| 336 | | |
| 337 | | |
| 338 | | pub behind: Option<usize>, |
| 339 | | |
| 340 | | pub follow: Follow, |
| 341 | | |
| 342 | | pub bump: Option<i64>, |
| 343 | | |
| 344 | | pub run: Option<(i64, String)>, |
| 345 | | |
| 346 | | pub landed: Option<(i64, i64)>, |
| 347 | | |
| 348 | | pub colour: Colour, |
| 349 | | |
| 350 | | pub upstream: Half, |
| 351 | | pub downstream: Half, |
| 352 | | } |
| 353 | |
|
| 354 | | impl Consumer { |
| 355 | | |
| 356 | 2× | pub fn api(&self, upstream: &str) -> hub_api::DiffusionPin { |
| 357 | 2× | let half = |h: &Half| hub_api::DiffusionHalf { |
| 358 | 4× | tone: h.class.trim_start_matches("df-").to_string(), |
| 359 | 4× | word: h.word.to_string(), |
| 360 | 4× | title: h.title.clone(), |
| 361 | 4× | }; |
| 362 | | hub_api::DiffusionPin { |
| 363 | 2× | upstream: upstream.to_string(), |
| 364 | 2× | consumer: format!("{}/{}", self.owner, self.name), |
| 365 | 2× | path: self.path.clone(), |
| 366 | 2× | bookmark: self.bookmark.clone(), |
| 367 | 2× | behind: self.behind.map(|n| n as u64), |
| 368 | 2× | follow: self.follow.as_str().to_string(), |
| 369 | 2× | bump: self.bump, |
| 370 | 2× | run: self.run.as_ref().map(|r| r.0), |
| 371 | 2× | run_status: self.run.as_ref().map(|r| r.1.clone()), |
| 372 | 2× | upstream_state: half(&self.upstream), |
| 373 | 2× | consumer_state: half(&self.downstream), |
| 374 | | } |
| 375 | 2× | } |
| 376 | | } |
| 377 | |
|
| 378 | |
|
| 379 | | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 380 | | pub struct Half { |
| 381 | | pub class: &'static str, |
| 382 | | pub word: &'static str, |
| 383 | | pub title: String, |
| 384 | | } |
| 385 | |
|
| 386 | | impl Half { |
| 387 | | |
| 388 | 10× | pub fn upstream(unverified: bool, run: Option<&str>, slug: &str) -> Half { |
| 389 | 10× | let (class, word, title) = match (unverified, run) { |
| 390 | 3× | (true, _) => ("df-amber", "untested", format!("{slug} has no checks of its own: nothing has tested it")), |
| 391 | 7× | (false, Some("passed")) => ("df-green", "checked", format!("{slug}'s newest run passed")), |
| 392 | 0× | (false, Some("failed" | "error" | "cancelled")) => ("df-red", "failing", format!("{slug}'s newest run failed")), |
| 393 | 0× | (false, _) => ("df-grey", "testing", format!("{slug}'s newest run has not finished")), |
| 394 | | }; |
| 395 | 10× | Half { class, word, title } |
| 396 | 10× | } |
| 397 | |
|
| 398 | | |
| 399 | 10× | pub fn downstream(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, name: &str) -> Half { |
| 400 | 10× | let (class, word, title) = if follow == Follow::Off { |
| 401 | 3× | ("df-off", "not following", format!("{name} does not follow it")) |
| 402 | | } else { |
| 403 | 7× | match (bump, run) { |
| 404 | 5× | (Some(_), Some("passed")) => ("df-green", "passes", format!("{name}'s run on the bump passed")), |
| 405 | 0× | (Some(_), Some("failed" | "error" | "cancelled")) => ("df-red", "fails", format!("{name}'s run on the bump failed")), |
| 406 | 0× | (Some(_), Some(_)) => ("df-grey", "testing", format!("{name}'s run on the bump has not finished")), |
| 407 | 0× | (Some(_), None) => ("df-grey", "waiting", format!("{name}'s bump has no run yet")), |
| 408 | 2× | (None, _) if behind == Some(0) => ("df-green", "up to date", format!("{name} pins the newest state")), |
| 409 | 0× | (None, _) => ("df-grey", "waiting", format!("{name} is behind; its bump has not been made yet")), |
| 410 | | } |
| 411 | | }; |
| 412 | 10× | Half { class, word, title } |
| 413 | 10× | } |
| 414 | | } |
| 415 | |
|
| 416 | |
|
| 417 | |
|
| 418 | |
|
| 419 | |
|
| 420 | |
|
| 421 | | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
| 422 | | #[serde(rename_all = "kebab-case")] |
| 423 | | pub enum Colour { |
| 424 | | Green, |
| 425 | | Amber, |
| 426 | | Red, |
| 427 | | Grey, |
| 428 | | UpToDate, |
| 429 | | Off, |
| 430 | | } |
| 431 | |
|
| 432 | | impl Colour { |
| 433 | 0× | pub fn class(&self) -> &'static str { |
| 434 | 0× | match self { |
| 435 | 0× | Colour::Green => "df-green", |
| 436 | 0× | Colour::Amber => "df-amber", |
| 437 | 0× | Colour::Red => "df-red", |
| 438 | 0× | Colour::Grey => "df-grey", |
| 439 | 0× | Colour::UpToDate => "df-current", |
| 440 | 0× | Colour::Off => "df-off", |
| 441 | | } |
| 442 | 0× | } |
| 443 | |
|
| 444 | 0× | pub fn label(&self) -> &'static str { |
| 445 | 0× | match self { |
| 446 | 0× | Colour::Green => "green", |
| 447 | 0× | Colour::Amber => "amber: upstream unverified", |
| 448 | 0× | Colour::Red => "red: its run failed", |
| 449 | 0× | Colour::Grey => "testing", |
| 450 | 0× | Colour::UpToDate => "up to date", |
| 451 | 0× | Colour::Off => "not following", |
| 452 | | } |
| 453 | 0× | } |
| 454 | |
|
| 455 | | |
| 456 | 10× | pub fn of(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, unverified: bool) -> Colour { |
| 457 | 10× | if follow == Follow::Off { |
| 458 | 3× | return Colour::Off; |
| 459 | 7× | } |
| 460 | 7× | match (bump, run) { |
| 461 | 5× | (Some(_), Some("passed")) if unverified => Colour::Amber, |
| 462 | 5× | (Some(_), Some("passed")) => Colour::Green, |
| 463 | 0× | (Some(_), Some("failed" | "error" | "cancelled")) => Colour::Red, |
| 464 | 0× | (Some(_), _) => Colour::Grey, |
| 465 | 2× | (None, _) if behind == Some(0) => Colour::UpToDate, |
| 466 | 0× | (None, _) => Colour::Grey, |
| 467 | | } |
| 468 | 10× | } |
| 469 | | } |
| 470 | |
|
| 471 | |
|
| 472 | |
|
| 473 | 67× | pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> { |
| 474 | 67× | let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) }; |
| 475 | 67× | let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?; |
| 476 | 67× | let found: Vec<(i64, String, String, String)> = sqlx::query_as( |
| 477 | 67× | "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = $1 ORDER BY consumer_id, bookmark, path", |
| 478 | 67× | ) |
| 479 | 67× | .bind(hex::encode(id)) |
| 480 | 67× | .fetch_all(&app.db) |
| 481 | 67× | .await |
| 482 | 67× | .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?; |
| 483 | 67× | let mut out = Vec::new(); |
| 484 | 67× | let mut pinned_sets = Vec::new(); |
| 485 | 67× | let unverified = unverified(app, row).await; |
| 486 | 67× | for (consumer_id, bookmark, path, heads) in found { |
| 487 | 12× | let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue }; |
| 488 | 12× | if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) { |
| 489 | 2× | continue; |
| 490 | 10× | } |
| 491 | | |
| 492 | 10× | if bookmark != target(&app.db, &c).await { |
| 493 | 0× | continue; |
| 494 | 10× | } |
| 495 | 14× | let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect(); |
| 496 | 14× | pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>()); |
| 497 | 10× | let bump_bm = bump_bookmark(&path); |
| 498 | 10× | let mine: Vec<db::proposals::ProposalRow> = db::proposals::list(&app.db, c.id, &[]) |
| 499 | 10× | .await |
| 500 | 10× | .unwrap_or_default() |
| 501 | 10× | .into_iter() |
| 502 | 10× | .filter(|p| p.source_bookmark == bump_bm) |
| 503 | 10× | .collect(); |
| 504 | 10× | let bump = mine.iter().find(|p| p.state == "open" || p.state == "draft").map(|p| p.number); |
| 505 | 10× | let landed = mine.iter().filter(|p| p.state == "landed").max_by_key(|p| p.landed_ms).map(|p| (p.number, p.landed_ms)); |
| 506 | 10× | let run: Option<(i64, String)> = |
| 507 | 10× | sqlx::query_as("SELECT number, status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1") |
| 508 | 10× | .bind(c.id) |
| 509 | 10× | .bind(&bump_bm) |
| 510 | 10× | .fetch_optional(&app.db) |
| 511 | 10× | .await |
| 512 | 10× | .ok() |
| 513 | 10× | .flatten(); |
| 514 | 10× | let follow = rule(&app.config.diffusion.follow, &c, None, &path).follow; |
| 515 | 10× | out.push(Consumer { |
| 516 | 10× | owner: c.owner, |
| 517 | 10× | name: c.name, |
| 518 | 10× | bookmark, |
| 519 | 10× | path, |
| 520 | 10× | pinned, |
| 521 | 10× | behind: None, |
| 522 | 10× | follow, |
| 523 | 10× | bump, |
| 524 | 10× | run, |
| 525 | 10× | landed, |
| 526 | 10× | colour: Colour::Off, |
| 527 | 10× | upstream: Half { class: "df-grey", word: "", title: String::new() }, |
| 528 | 10× | downstream: Half { class: "df-grey", word: "", title: String::new() }, |
| 529 | 10× | }); |
| 530 | | } |
| 531 | 67× | if out.is_empty() { |
| 532 | 57× | return Ok(out); |
| 533 | 10× | } |
| 534 | 10× | let default = row.default_bookmark.clone(); |
| 535 | 10× | let behind = app |
| 536 | 10× | .repos |
| 537 | 10× | .read(&name, move |r, cache| { |
| 538 | 10× | let tip = heads_of(r, &default)?; |
| 539 | 10× | let tip = cache.summary(r, &tip)?; |
| 540 | 10× | Ok(pinned_sets |
| 541 | 10× | .iter() |
| 542 | 31× | .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count())) |
| 543 | 10× | .collect::<Vec<_>>()) |
| 544 | 10× | }) |
| 545 | 10× | .await?; |
| 546 | 10× | let up_run: Option<String> = |
| 547 | 10× | sqlx::query_scalar("SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1") |
| 548 | 10× | .bind(row.id) |
| 549 | 10× | .bind(&row.default_bookmark) |
| 550 | 10× | .fetch_optional(&app.db) |
| 551 | 10× | .await |
| 552 | 10× | .ok() |
| 553 | 10× | .flatten(); |
| 554 | 10× | let slug = format!("{}/{}", row.owner, row.name); |
| 555 | 10× | for (c, b) in out.iter_mut().zip(behind) { |
| 556 | 10× | c.behind = b; |
| 557 | 10× | let run = c.run.as_ref().map(|r| r.1.clone()); |
| 558 | 10× | c.colour = Colour::of(c.follow, b, c.bump, run.as_deref(), unverified); |
| 559 | 10× | c.upstream = Half::upstream(unverified, up_run.as_deref(), &slug); |
| 560 | 10× | c.downstream = Half::downstream(c.follow, b, c.bump, run.as_deref(), &format!("{}/{}", c.owner, c.name)); |
| 561 | | } |
| 562 | 10× | Ok(out) |
| 563 | 67× | } |
| 564 | |
|
| 565 | |
|
| 566 | |
|
| 567 | 58× | pub async fn follows(app: &Shared, consumer: &RepoRow, account: Option<&Account>) -> Vec<(RepoRow, Consumer)> { |
| 568 | 58× | let ups: Vec<i64> = |
| 569 | 58× | sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id IS NOT NULL") |
| 570 | 58× | .bind(consumer.id) |
| 571 | 58× | .fetch_all(&app.db) |
| 572 | 58× | .await |
| 573 | 58× | .unwrap_or_default(); |
| 574 | 58× | let mut out = Vec::new(); |
| 575 | 58× | for id in ups { |
| 576 | 3× | let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue }; |
| 577 | 3× | if !roles::allows(roles::role_for(&app.db, account, &up).await.ok().flatten(), crate::auth::Access::Read) { |
| 578 | 0× | continue; |
| 579 | 3× | } |
| 580 | 3× | for c in consumers(app, &up, account).await.unwrap_or_default() { |
| 581 | 3× | if c.owner == consumer.owner && c.name == consumer.name { |
| 582 | 3× | out.push((up.clone(), c)); |
| 583 | 3× | } |
| 584 | | } |
| 585 | | } |
| 586 | 58× | out |
| 587 | 58× | } |
| 588 | |
|
| 589 | |
|
| 590 | 1× | pub async fn everything(app: &Shared, account: Option<&Account>) -> Vec<(RepoRow, Vec<Consumer>, bool)> { |
| 591 | 1× | let ups: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL") |
| 592 | 1× | .fetch_all(&app.db) |
| 593 | 1× | .await |
| 594 | 1× | .unwrap_or_default(); |
| 595 | 1× | let mut out = Vec::new(); |
| 596 | 1× | for id in ups { |
| 597 | 0× | let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue }; |
| 598 | 0× | let list = consumers(app, &up, account).await.unwrap_or_default(); |
| 599 | 0× | let unverified = unverified(app, &up).await; |
| 600 | 0× | out.push((up, list, unverified)); |
| 601 | | } |
| 602 | 1× | out |
| 603 | 1× | } |
| 604 | |
|
| 605 | |
|
| 606 | |
|
| 607 | |
|
| 608 | |
|
| 609 | 14× | pub fn bump_message(path: &str, upstream: &str, heads: &[String]) -> String { |
| 610 | 14× | let first = heads.first().map(String::as_str).unwrap_or(""); |
| 611 | 14× | match heads.len() { |
| 612 | 1× | 0 | 1 => format!("Diffusion: move {path} to {upstream} {first}"), |
| 613 | 13× | n => format!("Diffusion: move {path} to {upstream} {first} (+{} heads)\n\nPins {upstream} at {}", n - 1, heads.join("+")), |
| 614 | | } |
| 615 | 14× | } |
| 616 | |
|
| 617 | 62× | pub fn bump_bookmark(path: &str) -> String { |
| 618 | 62× | format!("diffusion/{}", path.trim_matches('/')) |
| 619 | 62× | } |
| 620 | |
|
| 621 | |
|
| 622 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 623 | | pub enum Bumped { |
| 624 | | |
| 625 | | Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash>, state: String }, |
| 626 | | |
| 627 | | UpToDate, |
| 628 | | |
| 629 | | Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> }, |
| 630 | | |
| 631 | | Unknown, |
| 632 | | |
| 633 | | |
| 634 | | Conflicts, |
| 635 | | |
| 636 | | |
| 637 | | NoPin, |
| 638 | | } |
| 639 | |
|
| 640 | |
|
| 641 | |
|
| 642 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 643 | | pub enum Plan { |
| 644 | | |
| 645 | | To(Vec<ChangeHash>, usize), |
| 646 | | |
| 647 | | UpToDate, |
| 648 | | |
| 649 | | |
| 650 | | Unknown, |
| 651 | | |
| 652 | | Conflicts, |
| 653 | | } |
| 654 | |
|
| 655 | |
|
| 656 | |
|
| 657 | |
|
| 658 | |
|
| 659 | 18× | pub async fn plan(app: &Shared, upstream: &RepoName, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Result<Plan, crate::repos::RepoError> { |
| 660 | 18× | app.repos |
| 661 | 18× | .read(upstream, move |r, cache| { |
| 662 | 18× | let Ok(o) = cache.summary(r, &old) else { return Ok(Plan::Unknown) }; |
| 663 | 18× | let n = cache.summary(r, &new)?; |
| 664 | 32× | if o.changes.iter().all(|c| n.changes.contains(c)) { |
| 665 | 17× | let added = n.changes.len() - o.changes.len(); |
| 666 | 17× | return Ok(if added == 0 { Plan::UpToDate } else { Plan::To(new, added) }); |
| 667 | 1× | } |
| 668 | 1× | let mut both = new.clone(); |
| 669 | 1× | both.extend(old.iter().copied()); |
| 670 | 1× | let heads = r.load_state(&both)?.heads(); |
| 671 | 1× | let b = cache.summary(r, &heads)?; |
| 672 | 1× | if b.conflicted && !o.conflicted { |
| 673 | 0× | return Ok(Plan::Conflicts); |
| 674 | 1× | } |
| 675 | 1× | let added = b.changes.len() - o.changes.len(); |
| 676 | 1× | Ok(if added == 0 { Plan::UpToDate } else { Plan::To(heads, added) }) |
| 677 | 18× | }) |
| 678 | 18× | .await |
| 679 | 18× | } |
| 680 | |
|
| 681 | |
|
| 682 | |
|
| 683 | |
|
| 684 | 18× | pub async fn bump( |
| 685 | 18× | app: &Shared, |
| 686 | 18× | consumer: &RepoRow, |
| 687 | 18× | target: &str, |
| 688 | 18× | path: &str, |
| 689 | 18× | upstream: &RepoRow, |
| 690 | 18× | new_heads: Vec<ChangeHash>, |
| 691 | 18× | ) -> Result<Bumped, crate::repos::RepoError> { |
| 692 | | use oxy_core::identity::Domain; |
| 693 | | use oxy_core::subrepo::Pin; |
| 694 | 18× | let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name)) |
| 695 | | else { |
| 696 | 0× | return Ok(Bumped::NoPin); |
| 697 | | }; |
| 698 | 18× | let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?; |
| 699 | | |
| 700 | | |
| 701 | 18× | let (t, p) = (target.to_string(), path.trim_matches('/').to_string()); |
| 702 | 18× | let (current, bumped) = app |
| 703 | 18× | .repos |
| 704 | 18× | .read(&cname, move |r, cache| { |
| 705 | 36× | let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> { |
| 706 | 36× | let heads = heads_of(r, bookmark)?; |
| 707 | 36× | if heads.is_empty() { |
| 708 | 8× | return Ok(None); |
| 709 | 28× | } |
| 710 | 28× | let loaded = cache.load(r, &heads)?; |
| 711 | 28× | let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) }; |
| 712 | 28× | let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) }; |
| 713 | 28× | let Some(b) = blobs.first() else { return Ok(None) }; |
| 714 | 28× | Ok(Pin::decode(&r.read_blob(b)?).ok()) |
| 715 | 36× | }; |
| 716 | 18× | Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?)) |
| 717 | 18× | }) |
| 718 | 18× | .await?; |
| 719 | 18× | let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) }; |
| 720 | 18× | if repo != upstream_id { |
| 721 | 0× | return Ok(Bumped::NoPin); |
| 722 | 18× | } |
| 723 | 18× | let planned = match plan(app, &uname, old.clone(), new_heads.clone()).await? { |
| 724 | 16× | Plan::To(heads, _) => heads, |
| 725 | 2× | Plan::UpToDate => return Ok(Bumped::UpToDate), |
| 726 | 0× | Plan::Unknown => return Ok(Bumped::Unknown), |
| 727 | 0× | Plan::Conflicts => return Ok(Bumped::Conflicts), |
| 728 | | }; |
| 729 | 16× | let bookmark = pin_bookmark(&planned, &new_heads, &upstream.default_bookmark, bookmark); |
| 730 | 16× | let new_heads = planned; |
| 731 | 10× | if let Some(Pin::Oxy { heads: b, .. }) = &bumped { |
| 732 | 10× | let (mut b, mut n) = (b.clone(), new_heads.clone()); |
| 733 | 10× | b.sort(); |
| 734 | 10× | n.sort(); |
| 735 | 10× | if b == n { |
| 736 | 4× | return Ok(Bumped::Already { from: old, to: new_heads }); |
| 737 | 6× | } |
| 738 | 6× | } |
| 739 | 12× | let (key, secret) = |
| 740 | 12× | crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?; |
| 741 | 12× | let identity = app.hub_key.identity.clone(); |
| 742 | 12× | let upstream_label = format!("{}/{}", upstream.owner, upstream.name); |
| 743 | 12× | let (t, p) = (target.to_string(), path.trim_matches('/').to_string()); |
| 744 | 12× | let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark }; |
| 745 | 12× | let old = old.clone(); |
| 746 | 39× | let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect(); |
| 747 | 12× | let message = bump_message(&p, &upstream_label, &short); |
| 748 | 12× | app.repos |
| 749 | 12× | .write(&cname, move |r, _| { |
| 750 | 12× | let base = heads_of(r, &t)?; |
| 751 | 12× | let change = r.build_pin_change(&base, &p, &pin, &message, key)?; |
| 752 | 12× | let hash = change.hash(); |
| 753 | 12× | let sig = secret.sign(Domain::Change, &key, &hash); |
| 754 | 12× | let mut state = r.load_state(&base)?; |
| 755 | 12× | state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?; |
| 756 | 12× | let mut candidates = base.clone(); |
| 757 | 12× | candidates.push(hash); |
| 758 | 12× | let heads = state.maximal(&candidates); |
| 759 | 12× | let bookmark = bump_bookmark(&p); |
| 760 | | |
| 761 | | |
| 762 | 12× | let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?; |
| 763 | 12× | update.force = true; |
| 764 | 12× | let state = update.new_state.to_hex(); |
| 765 | 12× | let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?; |
| 766 | 12× | if let Some(refused) = report.results.iter().find(|x| !x.ok) { |
| 767 | 0× | return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!( |
| 768 | 0× | "the bump was refused: {}", |
| 769 | 0× | refused.reason |
| 770 | 0× | )))); |
| 771 | 12× | } |
| 772 | 12× | Ok(Bumped::Moved { bookmark, change: hash, heads, from: old, state }) |
| 773 | 12× | }) |
| 774 | 12× | .await |
| 775 | 18× | } |
| 776 | |
|
| 777 | |
|
| 778 | |
|
| 779 | |
|
| 780 | |
|
| 781 | |
|
| 782 | |
|
| 783 | |
|
| 784 | 19× | fn pin_bookmark(planned: &[ChangeHash], new: &[ChangeHash], default: &str, old: String) -> String { |
| 785 | 19× | if heads_key(planned) == heads_key(new) { |
| 786 | 17× | default.to_string() |
| 787 | | } else { |
| 788 | 2× | old |
| 789 | | } |
| 790 | 19× | } |
| 791 | |
|
| 792 | |
|
| 793 | | pub const ACCOUNT: &str = "diffusion"; |
| 794 | |
|
| 795 | |
|
| 796 | 13× | pub async fn account(pool: &Pool) -> Option<Account> { |
| 797 | 13× | if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await { |
| 798 | 9× | return Some(a); |
| 799 | 4× | } |
| 800 | 4× | db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok() |
| 801 | 13× | } |
| 802 | |
|
| 803 | |
|
| 804 | |
|
| 805 | |
|
| 806 | |
|
| 807 | 9× | pub fn watched(pattern: &str, path: &str) -> bool { |
| 808 | 111× | fn m(p: &[u8], s: &[u8]) -> bool { |
| 809 | 111× | match (p.first(), s.first()) { |
| 810 | 5× | (None, None) => true, |
| 811 | 10× | (Some(b'*'), _) if p.get(1) == Some(&b'*') => (0..=s.len()).any(|i| m(&p[2..], &s[i..])), |
| 812 | 17× | (Some(b'*'), _) => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| m(&p[1..], &s[i..])), |
| 813 | 1× | (Some(b'?'), Some(c)) if *c != b'/' => m(&p[1..], &s[1..]), |
| 814 | 90× | (Some(a), Some(b)) if a == b => m(&p[1..], &s[1..]), |
| 815 | 26× | _ => false, |
| 816 | | } |
| 817 | 111× | } |
| 818 | 9× | let p = pattern.trim().trim_matches('/'); |
| 819 | 9× | if p.is_empty() { |
| 820 | 1× | return false; |
| 821 | 8× | } |
| 822 | 8× | m(p.as_bytes(), path.as_bytes()) || (!p.contains(['*', '?']) && path.starts_with(&format!("{p}/"))) |
| 823 | 9× | } |
| 824 | |
|
| 825 | |
|
| 826 | |
|
| 827 | |
|
| 828 | | pub struct Changelog { |
| 829 | | pub lines: Vec<String>, |
| 830 | | pub watched: Vec<String>, |
| 831 | | } |
| 832 | |
|
| 833 | 12× | pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>, watch: Vec<String>) -> Changelog { |
| 834 | 12× | let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Changelog { lines: Vec::new(), watched: Vec::new() } }; |
| 835 | 12× | let linked: std::collections::HashMap<String, String> = |
| 836 | 12× | db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect(); |
| 837 | 12× | let base = format!("/{}/{}", upstream.owner, upstream.name); |
| 838 | 12× | app.repos |
| 839 | 12× | .read(&name, move |r, cache| { |
| 840 | 12× | let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?); |
| 841 | 12× | let mut added: Vec<(u64, ChangeHash, String, String, Vec<String>)> = Vec::new(); |
| 842 | 44× | for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) { |
| 843 | 18× | let Ok(c) = r.store().get_change(h) else { continue }; |
| 844 | 18× | let key = c.author.to_hex(); |
| 845 | 18× | let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string()); |
| 846 | 18× | let first = c.message.lines().next().unwrap_or("").trim().to_string(); |
| 847 | | |
| 848 | 18× | let hits: Vec<String> = if watch.is_empty() { |
| 849 | 17× | Vec::new() |
| 850 | | } else { |
| 851 | 1× | crate::repos::view::change(r, cache, h) |
| 852 | 1× | .map(|d| d.files.into_iter().map(|f| f.path).filter(|p| watch.iter().any(|w| watched(w, p))).collect()) |
| 853 | 1× | .unwrap_or_default() |
| 854 | | }; |
| 855 | 18× | added.push((c.hlc.wall_ms, *h, first, who, hits)); |
| 856 | | } |
| 857 | 14× | added.sort_by_key(|a| std::cmp::Reverse(a.0)); |
| 858 | 19× | let line = |h: &ChangeHash, msg: &str, who: &str, flag: bool| { |
| 859 | 19× | format!( |
| 860 | | "- {}[`{}`]({base}/change/{}) {} ({who})", |
| 861 | 19× | if flag { "⚑ " } else { "" }, |
| 862 | 19× | &h.to_hex()[..12], |
| 863 | 19× | h.to_hex(), |
| 864 | 19× | msg.replace('\n', " ") |
| 865 | | ) |
| 866 | 19× | }; |
| 867 | 12× | let watched = added |
| 868 | 12× | .iter() |
| 869 | 18× | .filter(|a| !a.4.is_empty()) |
| 870 | 12× | .map(|(_, h, msg, who, hits)| { |
| 871 | 1× | format!( |
| 872 | | "{} — touches {}", |
| 873 | 1× | line(h, msg, who, true), |
| 874 | 1× | hits.iter().map(|p| format!("`{p}`")).collect::<Vec<_>>().join(", ") |
| 875 | | ) |
| 876 | 1× | }) |
| 877 | 12× | .collect(); |
| 878 | 18× | let lines = added.iter().map(|(_, h, msg, who, hits)| line(h, msg, who, !hits.is_empty())).collect(); |
| 879 | 12× | Ok(Changelog { lines, watched }) |
| 880 | 12× | }) |
| 881 | 12× | .await |
| 882 | 12× | .unwrap_or(Changelog { lines: Vec::new(), watched: Vec::new() }) |
| 883 | 12× | } |
| 884 | |
|
| 885 | |
|
| 886 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 887 | | pub enum Why { |
| 888 | | |
| 889 | | Green, |
| 890 | | |
| 891 | | Moved, |
| 892 | | |
| 893 | | |
| 894 | | Unverified, |
| 895 | | } |
| 896 | |
|
| 897 | |
|
| 898 | |
|
| 899 | |
|
| 900 | |
|
| 901 | 12× | pub async fn propose( |
| 902 | 12× | app: &Shared, |
| 903 | 12× | consumer: &RepoRow, |
| 904 | 12× | target: &str, |
| 905 | 12× | path: &str, |
| 906 | 12× | upstream: &RepoRow, |
| 907 | 12× | (from, to): (Vec<ChangeHash>, Vec<ChangeHash>), |
| 908 | 12× | (why, watch, lands): (Why, Vec<String>, bool), |
| 909 | 12× | ) -> Option<i64> { |
| 910 | | use crate::db::proposals as pdb; |
| 911 | 12× | let bookmark = bump_bookmark(path); |
| 912 | 12× | let bookmark = bookmark.as_str(); |
| 913 | 12× | let me = account(&app.db).await?; |
| 914 | 12× | let log = changelog(app, upstream, from, to.clone(), watch.clone()).await; |
| 915 | 12× | let lines = log.lines; |
| 916 | 12× | let flagged = if log.watched.is_empty() { |
| 917 | 11× | String::new() |
| 918 | | } else { |
| 919 | 1× | format!( |
| 920 | | "\n\n**Touches what you watch** ({}):\n\n{}", |
| 921 | 1× | watch.iter().map(|w| format!("`{w}`")).collect::<Vec<_>>().join(", "), |
| 922 | 1× | log.watched.join("\n") |
| 923 | | ) |
| 924 | | }; |
| 925 | 12× | let slug = format!("{}/{}", upstream.owner, upstream.name); |
| 926 | 44× | let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect(); |
| 927 | 12× | let ub = &upstream.default_bookmark; |
| 928 | 12× | let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) }; |
| 929 | 12× | let title = format!("Diffusion: {path} to {slug} {ub} ({count})"); |
| 930 | 12× | let when = match why { |
| 931 | 7× | Why::Green => "at its newest state whose checks passed", |
| 932 | 2× | Why::Moved => "as it is now", |
| 933 | 3× | Why::Unverified => "as it is now", |
| 934 | | }; |
| 935 | 12× | let amber = if why == Why::Unverified { |
| 936 | 3× | format!("\n\n> **Upstream unverified.** {slug} has no checks of its own, so nothing has tested this state of it. Only this repository's pipeline tests the move; a later bump from a passing run of {slug} clears this.") |
| 937 | | } else { |
| 938 | 9× | String::new() |
| 939 | | }; |
| 940 | 12× | let body = format!( |
| 941 | | "Moves the subrepo `{path}` to [{slug}](/{slug})'s `{ub}`, {when} (`{}`). Only the pin changes; this repository's own checks test it.{amber}{flagged}\n\n**What it brings** ({count}):\n\n{}\n\nOpened and kept up to date by Diffusion (docs/diffusion.md). {}", |
| 942 | 12× | crate::web::state_label(&short.join("+")), |
| 943 | 12× | lines.join("\n"), |
| 944 | 12× | if lands { |
| 945 | 2× | "This repository lands its bumps itself (`land = auto`): it lands once the upstream state it pins has passed its own checks and this repository's run on it passes, unless its required checks, approvals or trust policy say otherwise. An untested upstream always waits for a person." |
| 946 | | } else { |
| 947 | 10× | "It never lands by itself." |
| 948 | | } |
| 949 | | ); |
| 950 | 12× | let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark); |
| 951 | 12× | let (number, id) = match open { |
| 952 | 4× | Some(p) => { |
| 953 | 4× | if p.title != title || p.body != body { |
| 954 | 4× | pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?; |
| 955 | 4× | pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &crate::web::state_label(&short.join("+"))).await.ok(); |
| 956 | 0× | } |
| 957 | 4× | (p.number, p.id) |
| 958 | | } |
| 959 | | None => { |
| 960 | 8× | let p = pdb::create( |
| 961 | 8× | &app.db, |
| 962 | 8× | pdb::NewProposal { |
| 963 | 8× | repo_id: consumer.id, |
| 964 | 8× | title: &title, |
| 965 | 8× | body: &body, |
| 966 | 8× | author_id: Some(me.id), |
| 967 | 8× | author: ACCOUNT, |
| 968 | 8× | draft: false, |
| 969 | 8× | target, |
| 970 | 8× | source_kind: "bookmark", |
| 971 | 8× | source_repo_id: None, |
| 972 | 8× | source_bookmark: bookmark, |
| 973 | 8× | source_changes: "", |
| 974 | 8× | }, |
| 975 | 8× | ) |
| 976 | 8× | .await |
| 977 | 8× | .ok()?; |
| 978 | 8× | pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok(); |
| 979 | 8× | db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok(); |
| 980 | | |
| 981 | 8× | crate::notify::deliver( |
| 982 | 8× | app, |
| 983 | 8× | crate::notify::Event { |
| 984 | 8× | kind: "proposal.opened", |
| 985 | 8× | title: format!("{}/{}#{}: {}", consumer.owner, consumer.name, p.number, p.title), |
| 986 | 8× | url: format!("/{}/{}/proposal/{}", consumer.owner, consumer.name, p.number), |
| 987 | 8× | actor: &me, |
| 988 | 8× | repo_id: consumer.id, |
| 989 | 8× | thread: Some(("proposal", p.id)), |
| 990 | 8× | mention_text: "", |
| 991 | 8× | direct: vec![], |
| 992 | 8× | }, |
| 993 | 8× | ) |
| 994 | 8× | .await; |
| 995 | 8× | if let Ok(label) = |
| 996 | 8× | db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await |
| 997 | | { |
| 998 | 8× | db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok(); |
| 999 | 0× | } |
| 1000 | 8× | (p.number, p.id) |
| 1001 | | } |
| 1002 | | }; |
| 1003 | | |
| 1004 | 12× | let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten(); |
| 1005 | 12× | match (why == Why::Unverified, unverified) { |
| 1006 | 3× | (true, l) => { |
| 1007 | 3× | let l = match l { |
| 1008 | 0× | Some(l) => Some(l), |
| 1009 | 3× | None => db::issues::upsert_label( |
| 1010 | 3× | &app.db, |
| 1011 | 3× | consumer.id, |
| 1012 | 3× | "upstream unverified", |
| 1013 | 3× | "#e3a008", |
| 1014 | 3× | "The upstream has no checks (Diffusion)", |
| 1015 | 3× | ) |
| 1016 | 3× | .await |
| 1017 | 3× | .ok(), |
| 1018 | | }; |
| 1019 | 3× | if let Some(l) = l { |
| 1020 | 3× | db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok(); |
| 1021 | 0× | } |
| 1022 | | } |
| 1023 | 0× | (false, Some(l)) => { |
| 1024 | 0× | db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok(); |
| 1025 | | } |
| 1026 | 9× | (false, None) => {} |
| 1027 | | } |
| 1028 | 12× | Some(number) |
| 1029 | 12× | } |
| 1030 | |
|
| 1031 | |
|
| 1032 | |
|
| 1033 | |
|
| 1034 | 9× | fn start_runs( |
| 1035 | 9× | app: Shared, |
| 1036 | 9× | row: RepoRow, |
| 1037 | 9× | bookmark: String, |
| 1038 | 9× | state: String, |
| 1039 | 9× | heads: Vec<ChangeHash>, |
| 1040 | 9× | ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { |
| 1041 | 9× | Box::pin(async move { crate::flow::on_push(&app, &row, &bookmark, &state, &heads, ACCOUNT).await }) |
| 1042 | 9× | } |
| 1043 | |
|
| 1044 | |
|
| 1045 | 37× | async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> { |
| 1046 | 37× | let b = bookmark.to_string(); |
| 1047 | 37× | app.repos |
| 1048 | 37× | .read(name, move |r, cache| { |
| 1049 | 37× | let heads = heads_of(r, &b)?; |
| 1050 | 37× | if heads.is_empty() { |
| 1051 | 0× | return Ok(None); |
| 1052 | 37× | } |
| 1053 | 37× | let loaded = cache.load(r, &heads)?; |
| 1054 | 37× | Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) { |
| 1055 | 37× | Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()), |
| 1056 | 0× | _ => None, |
| 1057 | | }) |
| 1058 | 37× | }) |
| 1059 | 37× | .await |
| 1060 | 37× | .ok() |
| 1061 | 37× | .flatten() |
| 1062 | 37× | } |
| 1063 | |
|
| 1064 | |
|
| 1065 | |
|
| 1066 | |
|
| 1067 | 250× | pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) { |
| 1068 | 250× | propagate_to(app, upstream, heads, green, None).await |
| 1069 | 250× | } |
| 1070 | |
|
| 1071 | |
|
| 1072 | 260× | async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) { |
| 1073 | 260× | let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return }; |
| 1074 | 260× | let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return }; |
| 1075 | 260× | let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = $1") |
| 1076 | 260× | .bind(hex::encode(id)) |
| 1077 | 260× | .fetch_all(&app.db) |
| 1078 | 260× | .await |
| 1079 | 260× | .unwrap_or_default(); |
| 1080 | 260× | let unverified = unverified(app, upstream).await; |
| 1081 | 260× | for (consumer_id, indexed, path) in rows { |
| 1082 | 30× | if only.is_some_and(|o| o != consumer_id) { |
| 1083 | 2× | continue; |
| 1084 | 28× | } |
| 1085 | 28× | let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue }; |
| 1086 | 28× | let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue }; |
| 1087 | 28× | let learned = target(&app.db, &consumer).await; |
| 1088 | 28× | let modules = oxymodules(app, &cname, &learned).await; |
| 1089 | 28× | let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path); |
| 1090 | 28× | let target = r.target.clone().unwrap_or(learned); |
| 1091 | 28× | if indexed != target { |
| 1092 | 0× | continue; |
| 1093 | 28× | } |
| 1094 | | |
| 1095 | | |
| 1096 | 28× | let go = match r.follow { |
| 1097 | 5× | Follow::Off => false, |
| 1098 | 19× | Follow::Green => green || unverified, |
| 1099 | 4× | Follow::Any => !green, |
| 1100 | | }; |
| 1101 | 28× | let why = if green { |
| 1102 | 11× | Why::Green |
| 1103 | 17× | } else if unverified { |
| 1104 | 6× | Why::Unverified |
| 1105 | | } else { |
| 1106 | 11× | Why::Moved |
| 1107 | | }; |
| 1108 | 28× | if !go { |
| 1109 | 14× | continue; |
| 1110 | 14× | } |
| 1111 | | |
| 1112 | 14× | let Ok(_pin) = crate::leader::exclusive(&app.db, &format!("diffusion:{}:{path}", consumer.id)).await else { continue }; |
| 1113 | 14× | match bump(app, &consumer, &target, &path, upstream, heads.clone()).await { |
| 1114 | 9× | Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => { |
| 1115 | | |
| 1116 | 9× | tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped)); |
| 1117 | 9× | let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await; |
| 1118 | 9× | app.bus.live(&crate::events::topic::diffusion(consumer.id), "bump"); |
| 1119 | 9× | app.bus.live(&crate::events::topic::diffusion(upstream.id), "bump"); |
| 1120 | 9× | tracing::info!( |
| 1121 | | "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})", |
| 1122 | | consumer.owner, |
| 1123 | | consumer.name, |
| 1124 | | upstream.owner, |
| 1125 | | upstream.name |
| 1126 | | ); |
| 1127 | | } |
| 1128 | 3× | Ok(Bumped::Already { from, to }) => { |
| 1129 | | |
| 1130 | | |
| 1131 | 3× | propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone(), r.land_auto)).await; |
| 1132 | | |
| 1133 | | |
| 1134 | 3× | if green && r.land_auto { |
| 1135 | 0× | tokio::spawn(try_land(app.clone(), consumer.clone(), path.clone())); |
| 1136 | 3× | } |
| 1137 | | } |
| 1138 | 2× | Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name), |
| 1139 | 0× | Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name), |
| 1140 | | } |
| 1141 | | } |
| 1142 | 260× | } |
| 1143 | |
|
| 1144 | |
|
| 1145 | | #[derive(Debug, PartialEq, Eq)] |
| 1146 | | pub enum Held { |
| 1147 | | |
| 1148 | | NoBump, |
| 1149 | | |
| 1150 | | Manual, |
| 1151 | | |
| 1152 | | Unverified, |
| 1153 | | |
| 1154 | | UpstreamUnchecked, |
| 1155 | | |
| 1156 | | NotPassed, |
| 1157 | | |
| 1158 | | Refused(String), |
| 1159 | | } |
| 1160 | |
|
| 1161 | |
|
| 1162 | |
|
| 1163 | |
|
| 1164 | |
|
| 1165 | |
|
| 1166 | |
|
| 1167 | |
|
| 1168 | |
|
| 1169 | |
|
| 1170 | 8× | pub async fn auto_land(app: &Shared, consumer: &RepoRow, path: &str) -> Result<i64, Held> { |
| 1171 | 8× | let path = path.trim_matches('/'); |
| 1172 | 8× | let bm = bump_bookmark(path); |
| 1173 | 8× | let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { return Err(Held::NoBump) }; |
| 1174 | 8× | let bump = db::proposals::list(&app.db, consumer.id, &["open"]) |
| 1175 | 8× | .await |
| 1176 | 8× | .ok() |
| 1177 | 8× | .and_then(|ps| ps.into_iter().find(|p| p.source_bookmark == bm && p.author == ACCOUNT)) |
| 1178 | 8× | .ok_or(Held::NoBump)?; |
| 1179 | 7× | let learned = target(&app.db, consumer).await; |
| 1180 | 7× | let modules = oxymodules(app, &cname, &learned).await; |
| 1181 | 7× | if !rule(&app.config.diffusion.follow, consumer, modules.as_deref(), path).land_auto { |
| 1182 | 4× | return Err(Held::Manual); |
| 1183 | 3× | } |
| 1184 | 3× | let upstream_id: Option<i64> = sqlx::query_scalar( |
| 1185 | 3× | "SELECT upstream_repo_id FROM diffusion_pins WHERE consumer_id = $1 AND path = $2 AND upstream_repo_id IS NOT NULL LIMIT 1", |
| 1186 | 3× | ) |
| 1187 | 3× | .bind(consumer.id) |
| 1188 | 3× | .bind(path) |
| 1189 | 3× | .fetch_optional(&app.db) |
| 1190 | 3× | .await |
| 1191 | 3× | .ok() |
| 1192 | 3× | .flatten(); |
| 1193 | 3× | let Some(upstream) = (match upstream_id { |
| 1194 | 3× | Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten(), |
| 1195 | 0× | None => None, |
| 1196 | | }) else { |
| 1197 | 0× | return Err(Held::UpstreamUnchecked); |
| 1198 | | }; |
| 1199 | 3× | if unverified(app, &upstream).await { |
| 1200 | 2× | return Err(Held::Unverified); |
| 1201 | 1× | } |
| 1202 | | |
| 1203 | 1× | let (p, b) = (path.to_string(), bm.clone()); |
| 1204 | 1× | let (pinned, now) = app |
| 1205 | 1× | .repos |
| 1206 | 1× | .read(&cname, move |r, cache| { |
| 1207 | 1× | let heads = heads_of(r, &b)?; |
| 1208 | 1× | if heads.is_empty() { |
| 1209 | 0× | return Ok((None, heads)); |
| 1210 | 1× | } |
| 1211 | 1× | let loaded = cache.load(r, &heads)?; |
| 1212 | 1× | let pin = match loaded.snapshot.files.get(&p).map(|f| &f.content) { |
| 1213 | 1× | Some(oxy_core::state::FileContent::Opaque { blobs }) => match blobs.first() { |
| 1214 | 1× | Some(blob) => oxy_core::subrepo::Pin::decode(&r.read_blob(blob)?).ok(), |
| 1215 | 0× | None => None, |
| 1216 | | }, |
| 1217 | 0× | _ => None, |
| 1218 | | }; |
| 1219 | 1× | Ok((pin, heads)) |
| 1220 | 1× | }) |
| 1221 | 1× | .await |
| 1222 | 1× | .map_err(|e| Held::Refused(e.to_string()))?; |
| 1223 | 1× | let Some(oxy_core::subrepo::Pin::Oxy { heads: pinned, .. }) = pinned else { return Err(Held::UpstreamUnchecked) }; |
| 1224 | 2× | let same = |text: &str, want: &[ChangeHash]| { |
| 1225 | 3× | let mut a: Vec<String> = text.split('+').filter(|h| !h.is_empty()).map(str::to_string).collect(); |
| 1226 | 3× | let mut b: Vec<String> = want.iter().map(|h| h.to_hex()).collect(); |
| 1227 | 2× | a.sort(); |
| 1228 | 2× | b.sort(); |
| 1229 | 2× | a == b |
| 1230 | 2× | }; |
| 1231 | 1× | let upstream_runs: Vec<String> = sqlx::query_scalar( |
| 1232 | 1× | "SELECT heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND status = 'passed' ORDER BY id DESC LIMIT 100", |
| 1233 | 1× | ) |
| 1234 | 1× | .bind(upstream.id) |
| 1235 | 1× | .bind(&upstream.default_bookmark) |
| 1236 | 1× | .fetch_all(&app.db) |
| 1237 | 1× | .await |
| 1238 | 1× | .unwrap_or_default(); |
| 1239 | 1× | if !upstream_runs.iter().any(|h| same(h, &pinned)) { |
| 1240 | 0× | return Err(Held::UpstreamUnchecked); |
| 1241 | 1× | } |
| 1242 | 1× | let run: Option<(String, String)> = |
| 1243 | 1× | sqlx::query_as("SELECT status, heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1") |
| 1244 | 1× | .bind(consumer.id) |
| 1245 | 1× | .bind(&bm) |
| 1246 | 1× | .fetch_optional(&app.db) |
| 1247 | 1× | .await |
| 1248 | 1× | .ok() |
| 1249 | 1× | .flatten(); |
| 1250 | 1× | if !run.is_some_and(|(status, heads)| status == "passed" && same(&heads, &now)) { |
| 1251 | 0× | return Err(Held::NotPassed); |
| 1252 | 1× | } |
| 1253 | 1× | let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?; |
| 1254 | 1× | let via = "Diffusion, both halves green"; |
| 1255 | 1× | match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true, None).await { |
| 1256 | | Ok(_) => { |
| 1257 | 1× | tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number); |
| 1258 | 1× | Ok(bump.number) |
| 1259 | | } |
| 1260 | 0× | Err(crate::web::proposals::Landing::NotFound) => Err(Held::NoBump), |
| 1261 | 0× | Err(crate::web::proposals::Landing::Refused(why)) => { |
| 1262 | | |
| 1263 | 0× | let said = db::proposals::events(&app.db, bump.id) |
| 1264 | 0× | .await |
| 1265 | 0× | .unwrap_or_default() |
| 1266 | 0× | .iter() |
| 1267 | 0× | .any(|e| e.event == "not landed by itself" && e.detail == why); |
| 1268 | 0× | if !said { |
| 1269 | 0× | db::proposals::add_event(&app.db, bump.id, ACCOUNT, "not landed by itself", &why).await.ok(); |
| 1270 | 0× | } |
| 1271 | 0× | Err(Held::Refused(why)) |
| 1272 | | } |
| 1273 | | } |
| 1274 | 8× | } |
| 1275 | |
|
| 1276 | |
|
| 1277 | |
|
| 1278 | 7× | pub fn try_land(app: Shared, consumer: RepoRow, path: String) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { |
| 1279 | 7× | Box::pin(async move { |
| 1280 | | |
| 1281 | 7× | let Ok(_pin) = crate::leader::exclusive(&app.db, &format!("diffusion:{}:{path}", consumer.id)).await else { return }; |
| 1282 | 7× | match auto_land(&app, &consumer, &path).await { |
| 1283 | 6× | Ok(_) | Err(Held::NoBump | Held::Manual) => {} |
| 1284 | 1× | Err(why) => tracing::info!("diffusion: {}/{} {path} waits for a person: {why:?}", consumer.owner, consumer.name), |
| 1285 | | } |
| 1286 | 7× | }) |
| 1287 | 7× | } |
| 1288 | |
|
| 1289 | |
|
| 1290 | 1× | pub async fn land_ready(app: &Shared, consumer: &RepoRow) { |
| 1291 | 1× | let paths: Vec<String> = sqlx::query_scalar("SELECT DISTINCT path FROM diffusion_pins WHERE consumer_id = $1") |
| 1292 | 1× | .bind(consumer.id) |
| 1293 | 1× | .fetch_all(&app.db) |
| 1294 | 1× | .await |
| 1295 | 1× | .unwrap_or_default(); |
| 1296 | 1× | for path in paths { |
| 1297 | 1× | tokio::spawn(try_land(app.clone(), consumer.clone(), path)); |
| 1298 | 1× | } |
| 1299 | 1× | } |
| 1300 | |
|
| 1301 | |
|
| 1302 | |
|
| 1303 | |
|
| 1304 | 333× | pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool { |
| 1305 | 333× | if !crate::flow::enabled(app, upstream) { |
| 1306 | 126× | return true; |
| 1307 | 207× | } |
| 1308 | 207× | let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = $1 AND bookmark = $2") |
| 1309 | 207× | .bind(upstream.id) |
| 1310 | 207× | .bind(&upstream.default_bookmark) |
| 1311 | 207× | .fetch_one(&app.db) |
| 1312 | 207× | .await |
| 1313 | 207× | .unwrap_or(0); |
| 1314 | 207× | runs == 0 |
| 1315 | 333× | } |
| 1316 | |
|
| 1317 | |
|
| 1318 | 23× | async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> { |
| 1319 | 43× | for row in db::repos::list(&app.db).await.ok()? { |
| 1320 | 43× | let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 1321 | 43× | if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) { |
| 1322 | 23× | return Some(row); |
| 1323 | 20× | } |
| 1324 | | } |
| 1325 | 0× | None |
| 1326 | 23× | } |
| 1327 | |
|
| 1328 | |
|
| 1329 | 10× | async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> { |
| 1330 | 10× | let heads: String = sqlx::query_scalar( |
| 1331 | 10× | "SELECT heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND status = 'passed' ORDER BY id DESC LIMIT 1", |
| 1332 | 10× | ) |
| 1333 | 10× | .bind(upstream.id) |
| 1334 | 10× | .bind(&upstream.default_bookmark) |
| 1335 | 10× | .fetch_optional(&app.db) |
| 1336 | 10× | .await |
| 1337 | 10× | .ok() |
| 1338 | 10× | .flatten()?; |
| 1339 | 8× | let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect(); |
| 1340 | 8× | (!h.is_empty()).then_some(h) |
| 1341 | 10× | } |
| 1342 | |
|
| 1343 | |
|
| 1344 | |
|
| 1345 | |
|
| 1346 | 6× | pub async fn catch_up(app: &Shared, consumer: &RepoRow) { |
| 1347 | 6× | let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = $1") |
| 1348 | 6× | .bind(consumer.id) |
| 1349 | 6× | .fetch_all(&app.db) |
| 1350 | 6× | .await |
| 1351 | 6× | .unwrap_or_default(); |
| 1352 | 6× | for id in upstreams { |
| 1353 | 6× | let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue }; |
| 1354 | 6× | if let Some(h) = last_green(app, &upstream).await { |
| 1355 | 4× | propagate_to(app, &upstream, h, true, Some(consumer.id)).await; |
| 1356 | 2× | } |
| 1357 | 6× | let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue }; |
| 1358 | 6× | let b = upstream.default_bookmark.clone(); |
| 1359 | 6× | if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await { |
| 1360 | 6× | propagate_to(app, &upstream, h, false, Some(consumer.id)).await; |
| 1361 | 0× | } |
| 1362 | | } |
| 1363 | 6× | } |
| 1364 | |
|
| 1365 | |
|
| 1366 | | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
| 1367 | | #[serde(rename_all = "lowercase")] |
| 1368 | | pub enum Follow { |
| 1369 | | Off, |
| 1370 | | |
| 1371 | | Green, |
| 1372 | | |
| 1373 | | Any, |
| 1374 | | } |
| 1375 | |
|
| 1376 | | impl Follow { |
| 1377 | 79× | pub fn parse(s: &str) -> Option<Follow> { |
| 1378 | 79× | match s.trim() { |
| 1379 | 79× | "off" => Some(Follow::Off), |
| 1380 | 68× | "green" => Some(Follow::Green), |
| 1381 | 22× | "any" => Some(Follow::Any), |
| 1382 | 12× | _ => None, |
| 1383 | | } |
| 1384 | 79× | } |
| 1385 | |
|
| 1386 | 2× | pub fn as_str(self) -> &'static str { |
| 1387 | 2× | match self { |
| 1388 | 0× | Follow::Off => "off", |
| 1389 | 2× | Follow::Green => "green", |
| 1390 | 0× | Follow::Any => "any", |
| 1391 | | } |
| 1392 | 2× | } |
| 1393 | | } |
| 1394 | |
|
| 1395 | |
|
| 1396 | |
|
| 1397 | | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 1398 | | pub struct Rule { |
| 1399 | | pub follow: Follow, |
| 1400 | | |
| 1401 | | |
| 1402 | | pub land_auto: bool, |
| 1403 | | |
| 1404 | | pub target: Option<String>, |
| 1405 | | |
| 1406 | | |
| 1407 | | pub watch: Vec<String>, |
| 1408 | | |
| 1409 | | pub from: &'static str, |
| 1410 | | } |
| 1411 | |
|
| 1412 | |
|
| 1413 | |
|
| 1414 | 47× | pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) { |
| 1415 | 47× | let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new()); |
| 1416 | 47× | let mut here = false; |
| 1417 | 47× | let mut lines = text.lines(); |
| 1418 | 289× | while let Some(line) = lines.next() { |
| 1419 | 242× | let line = line.trim(); |
| 1420 | 242× | if line.is_empty() || line.starts_with('#') { |
| 1421 | 52× | continue; |
| 1422 | 190× | } |
| 1423 | 190× | if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { |
| 1424 | 54× | here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/'); |
| 1425 | 54× | continue; |
| 1426 | 136× | } |
| 1427 | 136× | let (true, Some((k, v))) = (here, line.split_once('=')) else { continue }; |
| 1428 | | |
| 1429 | | |
| 1430 | 111× | let mut v = v.trim().to_string(); |
| 1431 | 111× | if v.starts_with('[') && !v.contains(']') { |
| 1432 | 3× | for more in lines.by_ref() { |
| 1433 | 3× | let more = more.trim(); |
| 1434 | 3× | if more.starts_with('#') { |
| 1435 | 1× | continue; |
| 1436 | 2× | } |
| 1437 | 2× | v.push_str(more); |
| 1438 | 2× | if more.contains(']') { |
| 1439 | 1× | break; |
| 1440 | 1× | } |
| 1441 | | } |
| 1442 | 110× | } |
| 1443 | 111× | let v = v.as_str(); |
| 1444 | 111× | let unquoted = v.trim_matches('"').trim(); |
| 1445 | 111× | match k.trim() { |
| 1446 | 111× | "follow" => follow = Follow::parse(unquoted), |
| 1447 | 101× | "land" => land = Some(unquoted == "auto"), |
| 1448 | 94× | "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()), |
| 1449 | 90× | "watch" => { |
| 1450 | 8× | watch = v |
| 1451 | 8× | .trim_start_matches('[') |
| 1452 | 8× | .trim_end_matches(']') |
| 1453 | 8× | .split(',') |
| 1454 | 15× | .map(|w| w.trim().trim_matches('"').trim().to_string()) |
| 1455 | 15× | .filter(|w| !w.is_empty()) |
| 1456 | 8× | .collect() |
| 1457 | | } |
| 1458 | 82× | _ => {} |
| 1459 | | } |
| 1460 | | } |
| 1461 | 47× | (follow, land, target, watch) |
| 1462 | 47× | } |
| 1463 | |
|
| 1464 | |
|
| 1465 | 65× | pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule { |
| 1466 | 65× | let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default(); |
| 1467 | 65× | let (follow, from) = match follow { |
| 1468 | 7× | Some(f) => (f, ".oxymodules"), |
| 1469 | 58× | None => match Follow::parse(&row.diffusion) { |
| 1470 | 47× | Some(f) => (f, "setting"), |
| 1471 | 11× | None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"), |
| 1472 | | }, |
| 1473 | | }; |
| 1474 | | |
| 1475 | 65× | let land_auto = land.unwrap_or(row.diffusion_land == "auto"); |
| 1476 | 65× | Rule { follow, land_auto, target, watch, from } |
| 1477 | 65× | } |
| 1478 | |
|
| 1479 | | #[cfg(test)] |
| 1480 | | mod tests { |
| 1481 | | use super::*; |
| 1482 | |
|
| 1483 | | const MODULES: &str = r#" |
| 1484 | | [rstar-api] |
| 1485 | | url = https://oxygenhub.io/doomsdayone/rstar-api |
| 1486 | | follow = green |
| 1487 | | land = "propose" |
| 1488 | | target = develop |
| 1489 | | watch = ["src/x16/irq*", "src/x16/frame*"] |
| 1490 | |
|
| 1491 | | [other] |
| 1492 | | url = https://example/other |
| 1493 | | follow = any |
| 1494 | | "#; |
| 1495 | |
|
| 1496 | 5× | fn row(diffusion: &str) -> RepoRow { |
| 1497 | 5× | RepoRow { |
| 1498 | 5× | id: 1, |
| 1499 | 5× | owner: "o".into(), |
| 1500 | 5× | name: "n".into(), |
| 1501 | 5× | description: String::new(), |
| 1502 | 5× | default_bookmark: "main".into(), |
| 1503 | 5× | visibility: "public".into(), |
| 1504 | 5× | created_ms: 0, |
| 1505 | 5× | updated_ms: 0, |
| 1506 | 5× | git_mirror: false, |
| 1507 | 5× | git_mirror_state: String::new(), |
| 1508 | 5× | forked_from: None, |
| 1509 | 5× | topics: String::new(), |
| 1510 | 5× | flow: String::new(), |
| 1511 | 5× | diffusion: diffusion.into(), |
| 1512 | 5× | ..Default::default() |
| 1513 | 5× | } |
| 1514 | 5× | } |
| 1515 | |
|
| 1516 | | #[test] |
| 1517 | 1× | fn landing_by_itself_is_chosen_per_repository_and_per_entry() { |
| 1518 | 1× | let mut r = row("green"); |
| 1519 | 1× | assert!(!rule("off", &r, None, "rstar-api").land_auto, "never by default"); |
| 1520 | 1× | r.diffusion_land = "auto".into(); |
| 1521 | 1× | assert!(rule("off", &r, None, "rstar-api").land_auto, "the setting"); |
| 1522 | 1× | assert!(!rule("off", &r, Some(MODULES), "rstar-api").land_auto, "the entry's `land = propose` wins"); |
| 1523 | 1× | assert!(rule("off", &r, Some(MODULES), "other").land_auto, "an entry without `land` takes the setting"); |
| 1524 | 1× | r.diffusion_land = String::new(); |
| 1525 | 1× | let auto = MODULES.replace(r#"land = "propose""#, "land = auto"); |
| 1526 | 1× | assert!(rule("off", &r, Some(&auto), "rstar-api").land_auto); |
| 1527 | 1× | } |
| 1528 | |
|
| 1529 | | #[test] |
| 1530 | 1× | fn watch_patterns_match_paths() { |
| 1531 | 1× | assert!(watched("src/x16/irq*", "src/x16/irq.s")); |
| 1532 | 1× | assert!(watched("src/x16/irq*", "src/x16/irq_wait.c")); |
| 1533 | 1× | assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment"); |
| 1534 | 1× | assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c")); |
| 1535 | 1× | assert!(watched("src/x16", "src/x16/bank.s"), "a directory"); |
| 1536 | 1× | assert!(!watched("src/x16", "src/x160/bank.s")); |
| 1537 | 1× | assert!(watched("include/r?m.h", "include/rim.h")); |
| 1538 | 1× | assert!(!watched("", "anything")); |
| 1539 | 1× | } |
| 1540 | |
|
| 1541 | | #[test] |
| 1542 | 1× | fn oxymodules_keys_are_read_per_entry() { |
| 1543 | 1× | let (f, land, target, watch) = module_keys(MODULES, "rstar-api"); |
| 1544 | 1× | assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop"))); |
| 1545 | 1× | assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]); |
| 1546 | 1× | assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any)); |
| 1547 | 1× | assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![])); |
| 1548 | 1× | assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]); |
| 1549 | | |
| 1550 | | |
| 1551 | 1× | let multi = "[rstar-api]\nurl = https://hub/x\n\nwatch = [\"src/x16/rbm*\",\n \"src/x16/rdm.c\",\n # a note\n \"src/mega65/rsm.c\"]\nfollow = \"green\"\n"; |
| 1552 | 1× | let (f, _, _, watch) = module_keys(multi, "rstar-api"); |
| 1553 | 1× | assert_eq!(watch, vec!["src/x16/rbm*", "src/x16/rdm.c", "src/mega65/rsm.c"]); |
| 1554 | 1× | assert_eq!(f, Some(Follow::Green), "the key after the list is still read"); |
| 1555 | 1× | } |
| 1556 | |
|
| 1557 | | #[test] |
| 1558 | 1× | fn oxymodules_wins_then_the_setting_then_the_site() { |
| 1559 | 1× | let r = rule("off", &row("any"), Some(MODULES), "rstar-api"); |
| 1560 | 1× | assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules")); |
| 1561 | 1× | let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api"); |
| 1562 | 1× | assert_eq!((r.follow, r.from), (Follow::Any, "setting")); |
| 1563 | 1× | let r = rule("green", &row(""), None, "rstar-api"); |
| 1564 | 1× | assert_eq!((r.follow, r.from), (Follow::Green, "site")); |
| 1565 | 1× | assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off); |
| 1566 | 1× | } |
| 1567 | | } |
| 1568 | |
|
| 1569 | | #[cfg(test)] |
| 1570 | | mod message_tests { |
| 1571 | | use super::bump_message; |
| 1572 | |
|
| 1573 | | #[test] |
| 1574 | 1× | fn a_bump_of_many_heads_has_a_short_title_and_the_whole_address_below() { |
| 1575 | 20× | let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect(); |
| 1576 | 1× | let m = bump_message("rstar-api", "doomsdayone/rstar-api", &heads); |
| 1577 | 1× | let (title, body) = m.split_once("\n\n").unwrap(); |
| 1578 | 1× | assert_eq!(title, "Diffusion: move rstar-api to doomsdayone/rstar-api 00a6c9d4746a (+19 heads)"); |
| 1579 | 1× | assert_eq!(body, format!("Pins doomsdayone/rstar-api at {}", heads.join("+"))); |
| 1580 | 1× | assert_eq!(bump_message("api", "andy/api", &heads[..1]), "Diffusion: move api to andy/api 00a6c9d4746a"); |
| 1581 | 1× | } |
| 1582 | | } |
| 1583 | |
|
| 1584 | | #[cfg(test)] |
| 1585 | | mod pin_tests { |
| 1586 | | use super::pin_bookmark; |
| 1587 | | use oxy_core::hash::ObjectRef; |
| 1588 | |
|
| 1589 | | #[test] |
| 1590 | 1× | fn a_bump_pins_the_upstreams_default_bookmark_unless_it_keeps_other_changes() { |
| 1591 | 1× | let (a, b) = (ObjectRef([1; 32]), ObjectRef([2; 32])); |
| 1592 | | |
| 1593 | | |
| 1594 | 1× | assert_eq!(pin_bookmark(&[a], &[a], "main", "x16-tilemap-double".into()), "main"); |
| 1595 | 1× | assert_eq!(pin_bookmark(&[b, a], &[a, b], "main", "feature".into()), "main", "in any order"); |
| 1596 | | |
| 1597 | | |
| 1598 | 1× | assert_eq!(pin_bookmark(&[a, b], &[a], "main", "feature".into()), "feature"); |
| 1599 | 1× | } |
| 1600 | | } |