OXYGENHUB

Coverage / crates/hub/src/diffusion.rs 90.7%

1073 of 1183 lines run, 168 of 342 functions · run #541 on main · run missed

1
//! Diffusion (#148, docs/diffusion.md): an upstream's changes reach the
2
//! repositories that pin it as a subrepo.
3
//!
4
//! D1 starts with the reverse pin index (§4.1): when a push moves a
5
//! repository's default bookmark, or the bookmark its work lands on, each
6
//! Oxygen subrepo pin in the new state is recorded. That answers "who
7
//! consumes this repository, at which state, and how far behind", which
8
//! an upstream's page shows as Used by.
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
/// How far back landed proposals teach where a repository's work lands.
18
pub const LEARN_DAYS: i64 = 90;
19
20
/// The bookmark a repository's work lands on (§4.3): the most common
21
/// target of its proposals landed in the last `LEARN_DAYS`, else its
22
/// default bookmark.
23333×
pub async fn target(pool: &Pool, row: &RepoRow) -> String {
24333×
    let since = db::now_ms() - LEARN_DAYS * 24 * 3600 * 1000;
25333×
    let learned: Option<String> = sqlx::query_scalar(
26333×
        "SELECT target FROM proposals WHERE repo_id = $1 AND state = 'landed' AND landed_ms >= $2
27333×
         GROUP BY target ORDER BY COUNT(*) DESC, MAX(landed_ms) DESC LIMIT 1",
28333×
    )
29333×
    .bind(row.id)
30333×
    .bind(since)
31333×
    .fetch_optional(pool)
32333×
    .await
33333×
    .ok()
34333×
    .flatten();
35333×
    learned.filter(|t| !t.is_empty()).unwrap_or_else(|| row.default_bookmark.clone())
36333×
}
37
38
/// The bookmarks whose pins are indexed: the default and the learned
39
/// target (one when they are the same).
40286×
pub async fn followed_bookmarks(pool: &Pool, row: &RepoRow) -> Vec<String> {
41286×
    let mut out = vec![row.default_bookmark.clone()];
42286×
    let t = target(pool, row).await;
43286×
    if !out.contains(&t) {
440×
        out.push(t);
45286×
    }
46286×
    out
47286×
}
48
49
/// Record `row`'s pins on `bookmark` at `heads`, replacing what was there.
50231×
pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) {
51
    // A failure is logged and tried once more after a moment (oxygenhub#210):
52
    // an index left behind shows a consumer behind with no bump.
53231×
    let repo = format!("{}/{}", row.owner, row.name);
54232×
    for attempt in 1..=2 {
55232×
        match try_record(app, row, bookmark, heads).await {
56
            Ok(()) => {
57228×
                if attempt > 1 {
581×
                    tracing::info!("diffusion: indexed {repo} {bookmark} on the second try");
59227×
                }
60228×
                return;
61
            }
624×
            Err(why) if attempt == 1 => {
634×
                tracing::warn!("diffusion: indexing {repo} {bookmark} failed ({why}); trying again");
644×
                tokio::time::sleep(RETRY_AFTER).await;
65
            }
660×
            Err(why) => {
670×
                FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
680×
                tracing::error!("diffusion: indexing {repo} {bookmark} failed again ({why}); its pins stay as they were");
69
            }
70
        }
71
    }
72228×
}
73
74
/// How long [`record`] waits before its second try.
75
const RETRY_AFTER: std::time::Duration = std::time::Duration::from_millis(250);
76
77
/// Indexing failures that the retry did not mend, since the hub started.
78
pub static FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
79
80
/// Failures to force, per repository (tests only: [`fail_next_records`]).
81
static FORCED: std::sync::Mutex<Option<std::collections::HashMap<String, u32>>> = std::sync::Mutex::new(None);
82
83
/// Make the next `n` index writes for a repository fail, as a busy
84
/// database would (for tests of the retry).
85
#[doc(hidden)]
861×
pub fn fail_next_records(app: &Shared, repo_id: i64, n: u32) {
871×
    FORCED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id), n);
881×
}
89
90
/// Forced failures not yet used (tests only).
91
#[doc(hidden)]
921×
pub fn forced_left(app: &Shared, repo_id: i64) -> u32 {
931×
    FORCED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().and_then(|m| m.get(&key(app, repo_id)).copied()).unwrap_or(0)
941×
}
95
96229×
fn forced(app: &Shared, repo_id: i64) -> bool {
97229×
    let mut forced = FORCED.lock().unwrap_or_else(|p| p.into_inner());
98229×
    match forced.as_mut().and_then(|m| m.get_mut(&key(app, repo_id))) {
993×
        Some(n) if *n > 0 => {
1001×
            *n -= 1;
1011×
            true
102
        }
103228×
        _ => false,
104
    }
105229×
}
106
107
/// One try at replacing a bookmark's indexed pins; which step failed and
108
/// why. A state that cannot be read leaves the index as it was rather than
109
/// emptying it.
110232×
async fn try_record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Result<(), String> {
111232×
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(()) };
112232×
    let pins = if heads.is_empty() {
1135×
        Vec::new()
114
    } else {
115227×
        crate::flow::pins_in(app, name, heads.to_vec()).await.map_err(|e| format!("reading the pins: {e}"))?
116
    };
117229×
    let now = db::now_ms();
118
    // The upstream's repository on this hub, when it is one.
119229×
    let mut upstreams = Vec::new();
120229×
    for (_, id, _) in &pins {
12113×
        upstreams.push(repo_by_oxy_id(app, &hex::encode(id)).await.map(|r| r.id));
122
    }
123229×
    if forced(app, row.id) {
1241×
        return Err("begin: forced for a test".into());
125228×
    }
126228×
    let mut tx = app.db.begin().await.map_err(|e| format!("begin: {e}"))?;
127228×
    sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = $1 AND bookmark = $2")
128228×
        .bind(row.id)
129228×
        .bind(bookmark)
130228×
        .execute(&mut *tx)
131228×
        .await
132228×
        .map_err(|e| format!("delete: {e}"))?;
133228×
    for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) {
13423×
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
13512×
        sqlx::query(
13612×
            "INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms, upstream_repo_id) VALUES ($1, $2, $3, $4, $5, $6, $7)",
13712×
        )
13812×
        .bind(row.id)
13912×
        .bind(bookmark)
14012×
        .bind(path)
14112×
        .bind(hex::encode(id))
14212×
        .bind(heads.join("+"))
14312×
        .bind(now)
14412×
        .bind(upstream_row)
14512×
        .execute(&mut *tx)
14612×
        .await
14712×
        .map_err(|e| format!("insert {path}: {e}"))?;
148
    }
149228×
    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")
150228×
        .bind(row.id)
151228×
        .bind(bookmark)
152228×
        .bind(heads_key(heads))
153228×
        .bind(now)
154228×
        .execute(&mut *tx)
155228×
        .await
156228×
        .map_err(|e| format!("noting the state: {e}"))?;
157228×
    tx.commit().await.map_err(|e| format!("commit: {e}"))?;
158228×
    for u in upstreams.iter().flatten() {
15912×
        mark(app, row.id);
16012×
        mark(app, *u);
16112×
    }
162228×
    Ok(())
163232×
}
164
165
/// After a push: index the pins of each moved bookmark that is followed.
166273×
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
167273×
    let followed = followed_bookmarks(&app.db, row).await;
168273×
    for (bookmark, _, heads) in moved {
169273×
        if followed.contains(bookmark) {
170225×
            record(app, row, bookmark, heads).await;
17148×
        }
172
    }
173270×
}
174
175
static INVOLVED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
176
17737×
fn key(app: &Shared, repo_id: i64) -> String {
17837×
    format!("{}#{repo_id}", app.repos.root().display())
17937×
}
180
18124×
fn mark(app: &Shared, repo_id: i64) {
18224×
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
18324×
}
184
185
/// Whether a repository takes part in Diffusion, as an upstream or a
186
/// consumer on this hub: its Diffusion tab shows (#150).
187540×
pub fn involved(app: &Shared, repo_id: i64) -> bool {
188540×
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
189540×
}
190
191
/// Index every repository's followed bookmarks, for a hub that had no
192
/// index before (run once at start, in the background).
193205×
pub async fn backfill(app: &Shared) {
194205×
    let Ok(rows) = db::repos::list(&app.db).await else { return };
195205×
    for row in rows {
1966×
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
1976×
        for bookmark in followed_bookmarks(&app.db, &row).await {
1986×
            let b = bookmark.clone();
1996×
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
2006×
            if let Ok(heads) = heads {
2015×
                record(app, &row, &bookmark, &heads).await;
2021×
            }
203
        }
204
    }
205205×
}
206
207
/// Heads as one comparable string: sorted, joined with `+`.
208279×
fn heads_key(heads: &[ChangeHash]) -> String {
209505×
    let mut h: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
210279×
    h.sort();
211279×
    h.join("+")
212279×
}
213
214
/// How often [`reconciler`] looks.
215
pub const RECONCILE_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
216
217
/// What one reconciling pass mended.
218
#[derive(Debug, Default, PartialEq, Eq)]
219
pub struct Repairs {
220
    /// Followed bookmarks whose index was behind the bookmark.
221
    pub reindexed: usize,
222
    /// Consumers behind an upstream's newest passing state with no open
223
    /// bump, bumped now.
224
    pub caught_up: usize,
225
}
226
227
/// Every [`RECONCILE_EVERY`], mend the pin index and the bumps it drives
228
/// (oxygenhub#210).
229205×
pub async fn reconciler(app: Shared) {
230
    loop {
231205×
        tokio::time::sleep(RECONCILE_EVERY).await;
2320×
        let r = reconcile(&app).await;
2330×
        if r != Repairs::default() {
2340×
            tracing::warn!("diffusion: reconciled: {} bookmarks re-indexed, {} consumers caught up", r.reindexed, r.caught_up);
2350×
        }
236
    }
237
}
238
239
/// One pass: re-index each followed bookmark whose indexed state is not
240
/// the state it is at now; then bump each consumer whose pin is not its
241
/// upstream's newest passing state and that has no open bump for it.
2423×
pub async fn reconcile(app: &Shared) -> Repairs {
2433×
    let mut repairs = Repairs::default();
2443×
    let Ok(rows) = db::repos::list(&app.db).await else { return repairs };
2457×
    for row in &rows {
2467×
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
2477×
        for bookmark in followed_bookmarks(&app.db, row).await {
2487×
            let b = bookmark.clone();
2497×
            let Ok(heads) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await else { continue };
2507×
            let indexed: Option<String> =
2517×
                sqlx::query_scalar("SELECT heads FROM diffusion_indexed WHERE consumer_id = $1 AND bookmark = $2")
2527×
                    .bind(row.id)
2537×
                    .bind(&bookmark)
2547×
                    .fetch_optional(&app.db)
2557×
                    .await
2567×
                    .ok()
2577×
                    .flatten();
2587×
            if indexed.as_deref() != Some(heads_key(&heads).as_str()) {
2591×
                tracing::info!("diffusion: {}/{} {bookmark}: the index was not at the bookmark; re-indexing", row.owner, row.name);
2601×
                record(app, row, &bookmark, &heads).await;
2611×
                repairs.reindexed += 1;
2626×
            }
263
        }
264
    }
265
    // Consumers behind with no open bump.
2663×
    let pins: Vec<(i64, String, String, String)> =
2673×
        sqlx::query_as("SELECT consumer_id, path, upstream, heads FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL")
2683×
            .fetch_all(&app.db)
2693×
            .await
2703×
            .unwrap_or_default();
2713×
    let mut caught = std::collections::HashSet::new();
2724×
    for (consumer_id, path, upstream, pinned) in pins {
2734×
        if caught.contains(&consumer_id) {
2740×
            continue;
2754×
        }
2764×
        let Some(up) = repo_by_oxy_id(app, &upstream).await else { continue };
2774×
        let Some(green) = last_green(app, &up).await else { continue };
2784×
        if heads_key(&green) == heads_key(&pinned.split('+').filter_map(ChangeHash::from_hex).collect::<Vec<_>>()) {
2792×
            continue;
2802×
        }
2812×
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
2822×
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
2832×
        let learned = target(&app.db, &consumer).await;
2842×
        let modules = oxymodules(app, &cname, &learned).await;
2852×
        if rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path).follow == Follow::Off {
2860×
            continue;
2872×
        }
288
        // The newest bump at this path: open or a draft is a bump already,
289
        // and one a person closed is a "no" the reconciler respects (the
290
        // upstream's next move proposes again as usual).
2912×
        let bump_bm = bump_bookmark(&path);
2922×
        let newest = || async {
2932×
            db::proposals::list(&app.db, consumer_id, &[])
2942×
                .await
2952×
                .unwrap_or_default()
2962×
                .into_iter()
2973×
                .filter(|p| p.source_bookmark == bump_bm)
2982×
                .max_by_key(|p| p.number)
2992×
                .map(|p| p.state)
3004×
        };
3012×
        let has_bump = || async { matches!(newest().await.as_deref(), Some("open" | "draft")) };
3022×
        if matches!(newest().await.as_deref(), Some("open" | "draft" | "closed")) {
3032×
            continue;
3040×
        }
3050×
        caught.insert(consumer_id);
3060×
        catch_up(app, &consumer).await;
3070×
        if has_bump().await {
3080×
            tracing::info!(
309
                "diffusion: {}/{} {path} was behind {}/{} with no bump; bumped",
310
                consumer.owner,
311
                consumer.name,
312
                up.owner,
313
                up.name
314
            );
3150×
            repairs.caught_up += 1;
3160×
        }
317
    }
3183×
    repairs
3193×
}
320
321
/// A bookmark's heads as the repository has them now (not a cached
322
/// list, which a push refreshes a moment later); empty when it has none.
323112×
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
324153×
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
325112×
}
326
327
/// One consumer of an upstream, as an upstream's page and API show it.
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
    /// The upstream heads it pins, hex.
335
    pub pinned: Vec<String>,
336
    /// Upstream changes on its default bookmark the pin lacks; `None` when
337
    /// the hub cannot tell (the pinned changes are not in the upstream).
338
    pub behind: Option<usize>,
339
    /// How it follows the upstream (from its setting, or the site's).
340
    pub follow: Follow,
341
    /// Its open bump proposal's number, if it has one.
342
    pub bump: Option<i64>,
343
    /// The newest run of its bump bookmark: number and status.
344
    pub run: Option<(i64, String)>,
345
    /// Its last landed bump: number and when.
346
    pub landed: Option<(i64, i64)>,
347
    /// What the views show (#150).
348
    pub colour: Colour,
349
    /// The pill's two halves: the upstream, then this consumer.
350
    pub upstream: Half,
351
    pub downstream: Half,
352
}
353
354
impl Consumer {
355
    /// As the API gives it (oxygenhub#153), with its upstream's name.
3562×
    pub fn api(&self, upstream: &str) -> hub_api::DiffusionPin {
3572×
        let half = |h: &Half| hub_api::DiffusionHalf {
3584×
            tone: h.class.trim_start_matches("df-").to_string(),
3594×
            word: h.word.to_string(),
3604×
            title: h.title.clone(),
3614×
        };
362
        hub_api::DiffusionPin {
3632×
            upstream: upstream.to_string(),
3642×
            consumer: format!("{}/{}", self.owner, self.name),
3652×
            path: self.path.clone(),
3662×
            bookmark: self.bookmark.clone(),
3672×
            behind: self.behind.map(|n| n as u64),
3682×
            follow: self.follow.as_str().to_string(),
3692×
            bump: self.bump,
3702×
            run: self.run.as_ref().map(|r| r.0),
3712×
            run_status: self.run.as_ref().map(|r| r.1.clone()),
3722×
            upstream_state: half(&self.upstream),
3732×
            consumer_state: half(&self.downstream),
374
        }
3752×
    }
376
}
377
378
/// One half of a Diffusion pill: a colour class and a word (#150).
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
    /// The upstream's half: was the upstream itself tested?
38810×
    pub fn upstream(unverified: bool, run: Option<&str>, slug: &str) -> Half {
38910×
        let (class, word, title) = match (unverified, run) {
3903×
            (true, _) => ("df-amber", "untested", format!("{slug} has no checks of its own: nothing has tested it")),
3917×
            (false, Some("passed")) => ("df-green", "checked", format!("{slug}'s newest run passed")),
3920×
            (false, Some("failed" | "error" | "cancelled")) => ("df-red", "failing", format!("{slug}'s newest run failed")),
3930×
            (false, _) => ("df-grey", "testing", format!("{slug}'s newest run has not finished")),
394
        };
39510×
        Half { class, word, title }
39610×
    }
397
398
    /// The consumer's half: does it pass with the upstream?
39910×
    pub fn downstream(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, name: &str) -> Half {
40010×
        let (class, word, title) = if follow == Follow::Off {
4013×
            ("df-off", "not following", format!("{name} does not follow it"))
402
        } else {
4037×
            match (bump, run) {
4045×
                (Some(_), Some("passed")) => ("df-green", "passes", format!("{name}'s run on the bump passed")),
4050×
                (Some(_), Some("failed" | "error" | "cancelled")) => ("df-red", "fails", format!("{name}'s run on the bump failed")),
4060×
                (Some(_), Some(_)) => ("df-grey", "testing", format!("{name}'s run on the bump has not finished")),
4070×
                (Some(_), None) => ("df-grey", "waiting", format!("{name}'s bump has no run yet")),
4082×
                (None, _) if behind == Some(0) => ("df-green", "up to date", format!("{name} pins the newest state")),
4090×
                (None, _) => ("df-grey", "waiting", format!("{name} is behind; its bump has not been made yet")),
410
            }
411
        };
41210×
        Half { class, word, title }
41310×
    }
414
}
415
416
/// A consumer's Diffusion status at a glance (#150, Andy's colours):
417
/// green when the upstream was checked and the consumer's run on the bump
418
/// passed; amber when the run passed but the upstream has no checks; red
419
/// when the run failed; grey while it runs; up to date when there is
420
/// nothing to bump; off when it does not follow.
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 {
4330×
    pub fn class(&self) -> &'static str {
4340×
        match self {
4350×
            Colour::Green => "df-green",
4360×
            Colour::Amber => "df-amber",
4370×
            Colour::Red => "df-red",
4380×
            Colour::Grey => "df-grey",
4390×
            Colour::UpToDate => "df-current",
4400×
            Colour::Off => "df-off",
441
        }
4420×
    }
443
4440×
    pub fn label(&self) -> &'static str {
4450×
        match self {
4460×
            Colour::Green => "green",
4470×
            Colour::Amber => "amber: upstream unverified",
4480×
            Colour::Red => "red: its run failed",
4490×
            Colour::Grey => "testing",
4500×
            Colour::UpToDate => "up to date",
4510×
            Colour::Off => "not following",
452
        }
4530×
    }
454
455
    /// The colour from what is known of a consumer.
45610×
    pub fn of(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, unverified: bool) -> Colour {
45710×
        if follow == Follow::Off {
4583×
            return Colour::Off;
4597×
        }
4607×
        match (bump, run) {
4615×
            (Some(_), Some("passed")) if unverified => Colour::Amber,
4625×
            (Some(_), Some("passed")) => Colour::Green,
4630×
            (Some(_), Some("failed" | "error" | "cancelled")) => Colour::Red,
4640×
            (Some(_), _) => Colour::Grey,
4652×
            (None, _) if behind == Some(0) => Colour::UpToDate,
4660×
            (None, _) => Colour::Grey,
467
        }
46810×
    }
469
}
470
471
/// The consumers of `row` that `account` may read, with how far behind
472
/// the upstream's default bookmark each pin is.
47368×
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
47468×
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
47568×
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
47668×
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
47768×
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = $1 ORDER BY consumer_id, bookmark, path",
47868×
    )
47968×
    .bind(hex::encode(id))
48068×
    .fetch_all(&app.db)
48168×
    .await
48268×
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
48368×
    let mut out = Vec::new();
48468×
    let mut pinned_sets = Vec::new();
48568×
    let unverified = unverified(app, row).await;
48668×
    for (consumer_id, bookmark, path, heads) in found {
48712×
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
48812×
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
4892×
            continue;
49010×
        }
491
        // One row per consumer and subrepo: the bookmark its work lands on.
49210×
        if bookmark != target(&app.db, &c).await {
4930×
            continue;
49410×
        }
49514×
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
49614×
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
49710×
        let bump_bm = bump_bookmark(&path);
49810×
        let mine: Vec<db::proposals::ProposalRow> = db::proposals::list(&app.db, c.id, &[])
49910×
            .await
50010×
            .unwrap_or_default()
50110×
            .into_iter()
50210×
            .filter(|p| p.source_bookmark == bump_bm)
50310×
            .collect();
50410×
        let bump = mine.iter().find(|p| p.state == "open" || p.state == "draft").map(|p| p.number);
50510×
        let landed = mine.iter().filter(|p| p.state == "landed").max_by_key(|p| p.landed_ms).map(|p| (p.number, p.landed_ms));
50610×
        let run: Option<(i64, String)> =
50710×
            sqlx::query_as("SELECT number, status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1")
50810×
                .bind(c.id)
50910×
                .bind(&bump_bm)
51010×
                .fetch_optional(&app.db)
51110×
                .await
51210×
                .ok()
51310×
                .flatten();
51410×
        let follow = rule(&app.config.diffusion.follow, &c, None, &path).follow;
51510×
        out.push(Consumer {
51610×
            owner: c.owner,
51710×
            name: c.name,
51810×
            bookmark,
51910×
            path,
52010×
            pinned,
52110×
            behind: None,
52210×
            follow,
52310×
            bump,
52410×
            run,
52510×
            landed,
52610×
            colour: Colour::Off,
52710×
            upstream: Half { class: "df-grey", word: "", title: String::new() },
52810×
            downstream: Half { class: "df-grey", word: "", title: String::new() },
52910×
        });
530
    }
53168×
    if out.is_empty() {
53258×
        return Ok(out);
53310×
    }
53410×
    let default = row.default_bookmark.clone();
53510×
    let behind = app
53610×
        .repos
53710×
        .read(&name, move |r, cache| {
53810×
            let tip = heads_of(r, &default)?;
53910×
            let tip = cache.summary(r, &tip)?;
54010×
            Ok(pinned_sets
54110×
                .iter()
54231×
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
54310×
                .collect::<Vec<_>>())
54410×
        })
54510×
        .await?;
54610×
    let up_run: Option<String> =
54710×
        sqlx::query_scalar("SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1")
54810×
            .bind(row.id)
54910×
            .bind(&row.default_bookmark)
55010×
            .fetch_optional(&app.db)
55110×
            .await
55210×
            .ok()
55310×
            .flatten();
55410×
    let slug = format!("{}/{}", row.owner, row.name);
55510×
    for (c, b) in out.iter_mut().zip(behind) {
55610×
        c.behind = b;
55710×
        let run = c.run.as_ref().map(|r| r.1.clone());
55810×
        c.colour = Colour::of(c.follow, b, c.bump, run.as_deref(), unverified);
55910×
        c.upstream = Half::upstream(unverified, up_run.as_deref(), &slug);
56010×
        c.downstream = Half::downstream(c.follow, b, c.bump, run.as_deref(), &format!("{}/{}", c.owner, c.name));
561
    }
56210×
    Ok(out)
56368×
}
564
565
/// What `consumer` follows, as its Follows panel shows: each upstream on
566
/// this hub it pins, with its status there.
56758×
pub async fn follows(app: &Shared, consumer: &RepoRow, account: Option<&Account>) -> Vec<(RepoRow, Consumer)> {
56858×
    let ups: Vec<i64> =
56958×
        sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id IS NOT NULL")
57058×
            .bind(consumer.id)
57158×
            .fetch_all(&app.db)
57258×
            .await
57358×
            .unwrap_or_default();
57458×
    let mut out = Vec::new();
57558×
    for id in ups {
5763×
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
5773×
        if !roles::allows(roles::role_for(&app.db, account, &up).await.ok().flatten(), crate::auth::Access::Read) {
5780×
            continue;
5793×
        }
5803×
        for c in consumers(app, &up, account).await.unwrap_or_default() {
5813×
            if c.owner == consumer.owner && c.name == consumer.name {
5823×
                out.push((up.clone(), c));
5833×
            }
584
        }
585
    }
58658×
    out
58758×
}
588
589
/// Every upstream on this hub with its consumers, for Admin › Diffusion.
5901×
pub async fn everything(app: &Shared, account: Option<&Account>) -> Vec<(RepoRow, Vec<Consumer>, bool)> {
5911×
    let ups: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL")
5921×
        .fetch_all(&app.db)
5931×
        .await
5941×
        .unwrap_or_default();
5951×
    let mut out = Vec::new();
5961×
    for id in ups {
5970×
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
5980×
        let list = consumers(app, &up, account).await.unwrap_or_default();
5990×
        let unverified = unverified(app, &up).await;
6000×
        out.push((up, list, unverified));
601
    }
6021×
    out
6031×
}
604
605
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
606
/// A bump change's message: its title names the upstream state by its
607
/// first head and head count, so a state of many heads does not make it
608
/// run on (oxygenhub#216); the whole address is in the body.
60914×
pub fn bump_message(path: &str, upstream: &str, heads: &[String]) -> String {
61014×
    let first = heads.first().map(String::as_str).unwrap_or("");
61114×
    match heads.len() {
6121×
        0 | 1 => format!("Diffusion: move {path} to {upstream} {first}"),
61313×
        n => format!("Diffusion: move {path} to {upstream} {first} (+{} heads)\n\nPins {upstream} at {}", n - 1, heads.join("+")),
614
    }
61514×
}
616
61760×
pub fn bump_bookmark(path: &str) -> String {
61860×
    format!("diffusion/{}", path.trim_matches('/'))
61960×
}
620
621
/// What a bump did.
622
#[derive(Clone, Debug, PartialEq, Eq)]
623
pub enum Bumped {
624
    /// The bump bookmark now holds the target plus one change moving the pin.
625
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash>, state: String },
626
    /// The pin already holds the upstream state.
627
    UpToDate,
628
    /// The bump bookmark already moves the pin to that state.
629
    Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> },
630
    /// The pin names changes the upstream does not hold: not followed.
631
    Unknown,
632
    /// Taking the upstream's state with the pin's own extra changes would
633
    /// conflict.
634
    Conflicts,
635
    /// The consumer has no Oxygen pin of that upstream at `path` on its
636
    /// target bookmark.
637
    NoPin,
638
}
639
640
/// Where a consumer's pin `old` moves when the upstream reaches `new`,
641
/// worked out in the upstream (§4.3).
642
#[derive(Clone, Debug, PartialEq, Eq)]
643
pub enum Plan {
644
    /// To these heads, which add this many changes.
645
    To(Vec<ChangeHash>, usize),
646
    /// Nothing to add.
647
    UpToDate,
648
    /// The pin names changes the upstream does not hold (rewritten, or
649
    /// from elsewhere): not followed.
650
    Unknown,
651
    /// Taking `new` with the pin's own extra changes would conflict.
652
    Conflicts,
653
}
654
655
/// The pin `old` moved to the upstream's `new`. When the pin has changes
656
/// `new` lacks (say, from an upstream proposal not landed yet), they are
657
/// kept: changes that do not touch each other combine, so the move is to
658
/// both, never dropping what the consumer already relies on.
65917×
pub async fn plan(app: &Shared, upstream: &RepoName, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Result<Plan, crate::repos::RepoError> {
66017×
    app.repos
66117×
        .read(upstream, move |r, cache| {
66217×
            let Ok(o) = cache.summary(r, &old) else { return Ok(Plan::Unknown) };
66317×
            let n = cache.summary(r, &new)?;
66427×
            if o.changes.iter().all(|c| n.changes.contains(c)) {
66516×
                let added = n.changes.len() - o.changes.len();
66616×
                return Ok(if added == 0 { Plan::UpToDate } else { Plan::To(new, added) });
6671×
            }
6681×
            let mut both = new.clone();
6691×
            both.extend(old.iter().copied());
6701×
            let heads = r.load_state(&both)?.heads();
6711×
            let b = cache.summary(r, &heads)?;
6721×
            if b.conflicted && !o.conflicted {
6730×
                return Ok(Plan::Conflicts);
6741×
            }
6751×
            let added = b.changes.len() - o.changes.len();
6761×
            Ok(if added == 0 { Plan::UpToDate } else { Plan::To(heads, added) })
67717×
        })
67817×
        .await
67917×
}
680
681
/// Move `consumer`'s pin of `upstream` at `path` to `new_heads`, on a bump
682
/// bookmark built from `target` (§4.3): one change, signed with the hub's
683
/// key, on the target's heads. The upstream decides forwardness.
68417×
pub async fn bump(
68517×
    app: &Shared,
68617×
    consumer: &RepoRow,
68717×
    target: &str,
68817×
    path: &str,
68917×
    upstream: &RepoRow,
69017×
    new_heads: Vec<ChangeHash>,
69117×
) -> Result<Bumped, crate::repos::RepoError> {
692
    use oxy_core::identity::Domain;
693
    use oxy_core::subrepo::Pin;
69417×
    let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name))
695
    else {
6960×
        return Ok(Bumped::NoPin);
697
    };
69817×
    let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?;
699
    // The consumer's current pin at `path` on its target, and on its bump
700
    // bookmark if it has one.
70117×
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
70217×
    let (current, bumped) = app
70317×
        .repos
70417×
        .read(&cname, move |r, cache| {
70534×
            let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> {
70634×
                let heads = heads_of(r, bookmark)?;
70734×
                if heads.is_empty() {
7088×
                    return Ok(None);
70926×
                }
71026×
                let loaded = cache.load(r, &heads)?;
71126×
                let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) };
71226×
                let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) };
71326×
                let Some(b) = blobs.first() else { return Ok(None) };
71426×
                Ok(Pin::decode(&r.read_blob(b)?).ok())
71534×
            };
71617×
            Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?))
71717×
        })
71817×
        .await?;
71917×
    let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) };
72017×
    if repo != upstream_id {
7210×
        return Ok(Bumped::NoPin);
72217×
    }
72317×
    let planned = match plan(app, &uname, old.clone(), new_heads.clone()).await? {
72415×
        Plan::To(heads, _) => heads,
7252×
        Plan::UpToDate => return Ok(Bumped::UpToDate),
7260×
        Plan::Unknown => return Ok(Bumped::Unknown),
7270×
        Plan::Conflicts => return Ok(Bumped::Conflicts),
728
    };
72915×
    let bookmark = pin_bookmark(&planned, &new_heads, &upstream.default_bookmark, bookmark);
73015×
    let new_heads = planned;
7319×
    if let Some(Pin::Oxy { heads: b, .. }) = &bumped {
7329×
        let (mut b, mut n) = (b.clone(), new_heads.clone());
7339×
        b.sort();
7349×
        n.sort();
7359×
        if b == n {
7363×
            return Ok(Bumped::Already { from: old, to: new_heads });
7376×
        }
7386×
    }
73912×
    let (key, secret) =
74012×
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?;
74112×
    let identity = app.hub_key.identity.clone();
74212×
    let upstream_label = format!("{}/{}", upstream.owner, upstream.name);
74312×
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
74412×
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
74512×
    let old = old.clone();
74639×
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
74712×
    let message = bump_message(&p, &upstream_label, &short);
74812×
    app.repos
74912×
        .write(&cname, move |r, _| {
75012×
            let base = heads_of(r, &t)?;
75112×
            let change = r.build_pin_change(&base, &p, &pin, &message, key)?;
75212×
            let hash = change.hash();
75312×
            let sig = secret.sign(Domain::Change, &key, &hash);
75412×
            let mut state = r.load_state(&base)?;
75512×
            state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
75612×
            let mut candidates = base.clone();
75712×
            candidates.push(hash);
75812×
            let heads = state.maximal(&candidates);
75912×
            let bookmark = bump_bookmark(&p);
760
            // The bump bookmark is the hub's own and is rebuilt on the
761
            // target each time, so the bump it replaces may go.
76212×
            let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?;
76312×
            update.force = true;
76412×
            let state = update.new_state.to_hex();
76512×
            let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?;
76612×
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
7670×
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
7680×
                    "the bump was refused: {}",
7690×
                    refused.reason
7700×
                ))));
77112×
            }
77212×
            Ok(Bumped::Moved { bookmark, change: hash, heads, from: old, state })
77312×
        })
77412×
        .await
77517×
}
776
777
/// The bookmark a bump's pin names (#332): the upstream's default
778
/// bookmark, which `new` (its heads) are from, unless the move keeps
779
/// pinned changes that bookmark lacks (an upstream proposal not landed
780
/// yet), which are only on the bookmark the pin named, `old`. Keeping
781
/// `old` always left a pin naming a proposal's bookmark after the
782
/// proposal had landed, and a checkout then counted the subrepo as on
783
/// another bookmark.
78418×
fn pin_bookmark(planned: &[ChangeHash], new: &[ChangeHash], default: &str, old: String) -> String {
78518×
    if heads_key(planned) == heads_key(new) {
78616×
        default.to_string()
787
    } else {
7882×
        old
789
    }
79018×
}
791
792
/// The account Diffusion's proposals are opened as.
793
pub const ACCOUNT: &str = "diffusion";
794
795
/// The `diffusion` account, made on first use with no way to sign in.
79612×
pub async fn account(pool: &Pool) -> Option<Account> {
79712×
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
7988×
        return Some(a);
7994×
    }
8004×
    db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok()
80112×
}
802
803
/// Whether `path` matches a `watch` pattern: `*` is any run of characters
804
/// within one path segment, `**` any run across segments, `?` one
805
/// character; a pattern without a wildcard also matches everything below
806
/// it, as a directory would.
8079×
pub fn watched(pattern: &str, path: &str) -> bool {
808111×
    fn m(p: &[u8], s: &[u8]) -> bool {
809111×
        match (p.first(), s.first()) {
8105×
            (None, None) => true,
81110×
            (Some(b'*'), _) if p.get(1) == Some(&b'*') => (0..=s.len()).any(|i| m(&p[2..], &s[i..])),
81217×
            (Some(b'*'), _) => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| m(&p[1..], &s[i..])),
8131×
            (Some(b'?'), Some(c)) if *c != b'/' => m(&p[1..], &s[1..]),
81490×
            (Some(a), Some(b)) if a == b => m(&p[1..], &s[1..]),
81526×
            _ => false,
816
        }
817111×
    }
8189×
    let p = pattern.trim().trim_matches('/');
8199×
    if p.is_empty() {
8201×
        return false;
8218×
    }
8228×
    m(p.as_bytes(), path.as_bytes()) || (!p.contains(['*', '?']) && path.starts_with(&format!("{p}/")))
8239×
}
824
825
/// What a bump brings: the upstream changes in `new` and not in `old`,
826
/// newest first, as Markdown lines linking to the upstream's change pages,
827
/// with those touching a `watch` pattern flagged (and listed again).
828
pub struct Changelog {
829
    pub lines: Vec<String>,
830
    pub watched: Vec<String>,
831
}
832
83311×
pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>, watch: Vec<String>) -> Changelog {
83411×
    let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Changelog { lines: Vec::new(), watched: Vec::new() } };
83511×
    let linked: std::collections::HashMap<String, String> =
83611×
        db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
83711×
    let base = format!("/{}/{}", upstream.owner, upstream.name);
83811×
    app.repos
83911×
        .read(&name, move |r, cache| {
84011×
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
84111×
            let mut added: Vec<(u64, ChangeHash, String, String, Vec<String>)> = Vec::new();
84238×
            for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) {
84317×
                let Ok(c) = r.store().get_change(h) else { continue };
84417×
                let key = c.author.to_hex();
84517×
                let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string());
84617×
                let first = c.message.lines().next().unwrap_or("").trim().to_string();
847
                // The watched paths it touches, when the game watches any.
84817×
                let hits: Vec<String> = if watch.is_empty() {
84916×
                    Vec::new()
850
                } else {
8511×
                    crate::repos::view::change(r, cache, h)
8521×
                        .map(|d| d.files.into_iter().map(|f| f.path).filter(|p| watch.iter().any(|w| watched(w, p))).collect())
8531×
                        .unwrap_or_default()
854
                };
85517×
                added.push((c.hlc.wall_ms, *h, first, who, hits));
856
            }
85714×
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
85818×
            let line = |h: &ChangeHash, msg: &str, who: &str, flag: bool| {
85918×
                format!(
860
                    "- {}[`{}`]({base}/change/{}) {} ({who})",
86118×
                    if flag { "⚑ " } else { "" },
86218×
                    &h.to_hex()[..12],
86318×
                    h.to_hex(),
86418×
                    msg.replace('\n', " ")
865
                )
86618×
            };
86711×
            let watched = added
86811×
                .iter()
86917×
                .filter(|a| !a.4.is_empty())
87011×
                .map(|(_, h, msg, who, hits)| {
8711×
                    format!(
872
                        "{} — touches {}",
8731×
                        line(h, msg, who, true),
8741×
                        hits.iter().map(|p| format!("`{p}`")).collect::<Vec<_>>().join(", ")
875
                    )
8761×
                })
87711×
                .collect();
87817×
            let lines = added.iter().map(|(_, h, msg, who, hits)| line(h, msg, who, !hits.is_empty())).collect();
87911×
            Ok(Changelog { lines, watched })
88011×
        })
88111×
        .await
88211×
        .unwrap_or(Changelog { lines: Vec::new(), watched: Vec::new() })
88311×
}
884
885
/// Why a bump was made, which its proposal says.
886
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
887
pub enum Why {
888
    /// The upstream's state passed its checks.
889
    Green,
890
    /// The upstream moved (the consumer follows every move).
891
    Moved,
892
    /// The upstream has no checks at all: nothing vouched for it, so the
893
    /// proposal is marked amber, `upstream unverified`.
894
    Unverified,
895
}
896
897
/// Open the bump proposal for `path`'s bump bookmark, or bring the open
898
/// one up to date: into `target`, by the `diffusion` account, labelled
899
/// `dependencies` (and `upstream unverified` while that is so), its body
900
/// the changelog from the pin `from` to `to`. Its number.
90111×
pub async fn propose(
90211×
    app: &Shared,
90311×
    consumer: &RepoRow,
90411×
    target: &str,
90511×
    path: &str,
90611×
    upstream: &RepoRow,
90711×
    (from, to): (Vec<ChangeHash>, Vec<ChangeHash>),
90811×
    (why, watch, lands): (Why, Vec<String>, bool),
90911×
) -> Option<i64> {
910
    use crate::db::proposals as pdb;
91111×
    let bookmark = bump_bookmark(path);
91211×
    let bookmark = bookmark.as_str();
91311×
    let me = account(&app.db).await?;
91411×
    let log = changelog(app, upstream, from, to.clone(), watch.clone()).await;
91511×
    let lines = log.lines;
91611×
    let flagged = if log.watched.is_empty() {
91710×
        String::new()
918
    } else {
9191×
        format!(
920
            "\n\n**Touches what you watch** ({}):\n\n{}",
9211×
            watch.iter().map(|w| format!("`{w}`")).collect::<Vec<_>>().join(", "),
9221×
            log.watched.join("\n")
923
        )
924
    };
92511×
    let slug = format!("{}/{}", upstream.owner, upstream.name);
92638×
    let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect();
92711×
    let ub = &upstream.default_bookmark;
92811×
    let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) };
92911×
    let title = format!("Diffusion: {path} to {slug} {ub} ({count})");
93011×
    let when = match why {
9316×
        Why::Green => "at its newest state whose checks passed",
9322×
        Why::Moved => "as it is now",
9333×
        Why::Unverified => "as it is now",
934
    };
93511×
    let amber = if why == Why::Unverified {
9363×
        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 {
9388×
        String::new()
939
    };
94011×
    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). {}",
94211×
        crate::web::state_label(&short.join("+")),
94311×
        lines.join("\n"),
94411×
        if lands {
9452×
            "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 {
9479×
            "It never lands by itself."
948
        }
949
    );
95011×
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
95111×
    let (number, id) = match open {
9524×
        Some(p) => {
9534×
            if p.title != title || p.body != body {
9544×
                pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
9554×
                pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &crate::web::state_label(&short.join("+"))).await.ok();
9560×
            }
9574×
            (p.number, p.id)
958
        }
959
        None => {
9607×
            let p = pdb::create(
9617×
                &app.db,
9627×
                pdb::NewProposal {
9637×
                    repo_id: consumer.id,
9647×
                    title: &title,
9657×
                    body: &body,
9667×
                    author_id: Some(me.id),
9677×
                    author: ACCOUNT,
9687×
                    draft: false,
9697×
                    target,
9707×
                    source_kind: "bookmark",
9717×
                    source_repo_id: None,
9727×
                    source_bookmark: bookmark,
9737×
                    source_changes: "",
9747×
                },
9757×
            )
9767×
            .await
9777×
            .ok()?;
9787×
            pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok();
9797×
            db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok();
980
            // Told as a proposal opened on the web is (oxygenhub#183).
9817×
            crate::notify::deliver(
9827×
                app,
9837×
                crate::notify::Event {
9847×
                    kind: "proposal.opened",
9857×
                    title: format!("{}/{}#{}: {}", consumer.owner, consumer.name, p.number, p.title),
9867×
                    url: format!("/{}/{}/proposal/{}", consumer.owner, consumer.name, p.number),
9877×
                    actor: &me,
9887×
                    repo_id: consumer.id,
9897×
                    thread: Some(("proposal", p.id)),
9907×
                    mention_text: "",
9917×
                    direct: vec![],
9927×
                },
9937×
            )
9947×
            .await;
9957×
            if let Ok(label) =
9967×
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
997
            {
9987×
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
9990×
            }
10007×
            (p.number, p.id)
1001
        }
1002
    };
1003
    // Amber while the upstream has no checks; cleared by a green bump.
100411×
    let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten();
100511×
    match (why == Why::Unverified, unverified) {
10063×
        (true, l) => {
10073×
            let l = match l {
10080×
                Some(l) => Some(l),
10093×
                None => db::issues::upsert_label(
10103×
                    &app.db,
10113×
                    consumer.id,
10123×
                    "upstream unverified",
10133×
                    "#e3a008",
10143×
                    "The upstream has no checks (Diffusion)",
10153×
                )
10163×
                .await
10173×
                .ok(),
1018
            };
10193×
            if let Some(l) = l {
10203×
                db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok();
10210×
            }
1022
        }
10230×
        (false, Some(l)) => {
10240×
            db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok();
1025
        }
10268×
        (false, None) => {}
1027
    }
102811×
    Some(number)
102911×
}
1030
1031
/// Start a bump's pipeline, as a push of it would. Boxed as `Send`, which
1032
/// breaks the cycle between a bump starting runs and a passing run
1033
/// starting bumps that the compiler cannot see through.
10349×
fn start_runs(
10359×
    app: Shared,
10369×
    row: RepoRow,
10379×
    bookmark: String,
10389×
    state: String,
10399×
    heads: Vec<ChangeHash>,
10409×
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
10419×
    Box::pin(async move { crate::flow::on_push(&app, &row, &bookmark, &state, &heads, ACCOUNT).await })
10429×
}
1043
1044
/// The text of `.oxymodules` on `bookmark` of `name`, if any.
104537×
async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> {
104637×
    let b = bookmark.to_string();
104737×
    app.repos
104837×
        .read(name, move |r, cache| {
104937×
            let heads = heads_of(r, &b)?;
105037×
            if heads.is_empty() {
10510×
                return Ok(None);
105237×
            }
105337×
            let loaded = cache.load(r, &heads)?;
105437×
            Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) {
105537×
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
10560×
                _ => None,
1057
            })
105837×
        })
105937×
        .await
106037×
        .ok()
106137×
        .flatten()
106237×
}
1063
1064
/// The upstream `upstream` reached `heads` on its default bookmark: by a
1065
/// push (`green` false) or a passing run of that state (`green` true).
1066
/// Bump and propose in every consumer that follows it that way (§4.3).
1067250×
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
1068250×
    propagate_to(app, upstream, heads, green, None).await
1069250×
}
1070
1071
/// `propagate`, to one consumer only when `only` names it.
1072260×
async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) {
1073260×
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
1074260×
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
1075260×
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = $1")
1076260×
        .bind(hex::encode(id))
1077260×
        .fetch_all(&app.db)
1078260×
        .await
1079260×
        .unwrap_or_default();
1080260×
    let unverified = unverified(app, upstream).await;
1081260×
    for (consumer_id, indexed, path) in rows {
108230×
        if only.is_some_and(|o| o != consumer_id) {
10832×
            continue;
108428×
        }
108528×
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
108628×
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
108728×
        let learned = target(&app.db, &consumer).await;
108828×
        let modules = oxymodules(app, &cname, &learned).await;
108928×
        let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path);
109028×
        let target = r.target.clone().unwrap_or(learned);
109128×
        if indexed != target {
10920×
            continue;
109328×
        }
1094
        // An upstream with no checks never turns green: its moves count
1095
        // for green followers too, marked amber (unverified).
109628×
        let go = match r.follow {
10976×
            Follow::Off => false,
109818×
            Follow::Green => green || unverified,
10994×
            Follow::Any => !green,
1100
        };
110128×
        let why = if green {
110211×
            Why::Green
110317×
        } else if unverified {
11046×
            Why::Unverified
1105
        } else {
110611×
            Why::Moved
1107
        };
110828×
        if !go {
110915×
            continue;
111013×
        }
1111
        // One bump or landing of this pin at a time across processes (#275).
111213×
        let Ok(_pin) = crate::leader::exclusive(&app.db, &format!("diffusion:{}:{path}", consumer.id)).await else { continue };
111313×
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
11149×
            Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => {
1115
                // Its pipeline runs as for a push, so the proposal is tested.
11169×
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
11179×
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await;
11189×
                app.bus.live(&crate::events::topic::diffusion(consumer.id), "bump");
11199×
                app.bus.live(&crate::events::topic::diffusion(upstream.id), "bump");
11209×
                tracing::info!(
1121
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
1122
                    consumer.owner,
1123
                    consumer.name,
1124
                    upstream.owner,
1125
                    upstream.name
1126
                );
1127
            }
11282×
            Ok(Bumped::Already { from, to }) => {
1129
                // Already moved: its proposal's text and labels are brought
1130
                // up to date all the same.
11312×
                propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone(), r.land_auto)).await;
1132
                // The upstream may just have turned green under a bump its
1133
                // consumer already passed.
11342×
                if green && r.land_auto {
11350×
                    tokio::spawn(try_land(app.clone(), consumer.clone(), path.clone()));
11362×
                }
1137
            }
11382×
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
11390×
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
1140
        }
1141
    }
1142260×
}
1143
1144
/// Why a bump was not landed by itself (docs/diffusion.md §4.2).
1145
#[derive(Debug, PartialEq, Eq)]
1146
pub enum Held {
1147
    /// No open bump at that path.
1148
    NoBump,
1149
    /// The consumer did not choose `land = auto`.
1150
    Manual,
1151
    /// The upstream has no checks: amber never lands by itself.
1152
    Unverified,
1153
    /// The upstream state the bump pins has no passing run.
1154
    UpstreamUnchecked,
1155
    /// The consumer's run on the bump as it is now has not passed.
1156
    NotPassed,
1157
    /// The landing was refused (required checks, approvals, the trust policy).
1158
    Refused(String),
1159
}
1160
1161
/// Land the open bump at `path` in `consumer` itself, as the `diffusion`
1162
/// account, when the consumer chose `land = auto` (its `.oxymodules` entry,
1163
/// else Settings › Diffusion) and both halves of its pill are green: the
1164
/// upstream state it pins passed its own run (never amber: an upstream
1165
/// with no checks always waits for a person), and the consumer's run on
1166
/// the bump as it is now passed. It lands through the web's own path
1167
/// (`land_as`), signed by the hub's key, so required checks, approvals and
1168
/// the trust policy decide as for anyone; a refusal is noted once on the
1169
/// proposal's timeline. The proposal's number when it landed.
11708×
pub async fn auto_land(app: &Shared, consumer: &RepoRow, path: &str) -> Result<i64, Held> {
11718×
    let path = path.trim_matches('/');
11728×
    let bm = bump_bookmark(path);
11738×
    let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { return Err(Held::NoBump) };
11748×
    let bump = db::proposals::list(&app.db, consumer.id, &["open"])
11758×
        .await
11768×
        .ok()
11778×
        .and_then(|ps| ps.into_iter().find(|p| p.source_bookmark == bm && p.author == ACCOUNT))
11788×
        .ok_or(Held::NoBump)?;
11797×
    let learned = target(&app.db, consumer).await;
11807×
    let modules = oxymodules(app, &cname, &learned).await;
11817×
    if !rule(&app.config.diffusion.follow, consumer, modules.as_deref(), path).land_auto {
11824×
        return Err(Held::Manual);
11833×
    }
11843×
    let upstream_id: Option<i64> = sqlx::query_scalar(
11853×
        "SELECT upstream_repo_id FROM diffusion_pins WHERE consumer_id = $1 AND path = $2 AND upstream_repo_id IS NOT NULL LIMIT 1",
11863×
    )
11873×
    .bind(consumer.id)
11883×
    .bind(path)
11893×
    .fetch_optional(&app.db)
11903×
    .await
11913×
    .ok()
11923×
    .flatten();
11933×
    let Some(upstream) = (match upstream_id {
11943×
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten(),
11950×
        None => None,
1196
    }) else {
11970×
        return Err(Held::UpstreamUnchecked);
1198
    };
11993×
    if unverified(app, &upstream).await {
12002×
        return Err(Held::Unverified);
12011×
    }
1202
    // What the bump pins, and the bump bookmark's heads now.
12031×
    let (p, b) = (path.to_string(), bm.clone());
12041×
    let (pinned, now) = app
12051×
        .repos
12061×
        .read(&cname, move |r, cache| {
12071×
            let heads = heads_of(r, &b)?;
12081×
            if heads.is_empty() {
12090×
                return Ok((None, heads));
12101×
            }
12111×
            let loaded = cache.load(r, &heads)?;
12121×
            let pin = match loaded.snapshot.files.get(&p).map(|f| &f.content) {
12131×
                Some(oxy_core::state::FileContent::Opaque { blobs }) => match blobs.first() {
12141×
                    Some(blob) => oxy_core::subrepo::Pin::decode(&r.read_blob(blob)?).ok(),
12150×
                    None => None,
1216
                },
12170×
                _ => None,
1218
            };
12191×
            Ok((pin, heads))
12201×
        })
12211×
        .await
12221×
        .map_err(|e| Held::Refused(e.to_string()))?;
12231×
    let Some(oxy_core::subrepo::Pin::Oxy { heads: pinned, .. }) = pinned else { return Err(Held::UpstreamUnchecked) };
12242×
    let same = |text: &str, want: &[ChangeHash]| {
12253×
        let mut a: Vec<String> = text.split('+').filter(|h| !h.is_empty()).map(str::to_string).collect();
12263×
        let mut b: Vec<String> = want.iter().map(|h| h.to_hex()).collect();
12272×
        a.sort();
12282×
        b.sort();
12292×
        a == b
12302×
    };
12311×
    let upstream_runs: Vec<String> = sqlx::query_scalar(
12321×
        "SELECT heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND status = 'passed' ORDER BY id DESC LIMIT 100",
12331×
    )
12341×
    .bind(upstream.id)
12351×
    .bind(&upstream.default_bookmark)
12361×
    .fetch_all(&app.db)
12371×
    .await
12381×
    .unwrap_or_default();
12391×
    if !upstream_runs.iter().any(|h| same(h, &pinned)) {
12400×
        return Err(Held::UpstreamUnchecked);
12411×
    }
12421×
    let run: Option<(String, String)> =
12431×
        sqlx::query_as("SELECT status, heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 ORDER BY id DESC LIMIT 1")
12441×
            .bind(consumer.id)
12451×
            .bind(&bm)
12461×
            .fetch_optional(&app.db)
12471×
            .await
12481×
            .ok()
12491×
            .flatten();
12501×
    if !run.is_some_and(|(status, heads)| status == "passed" && same(&heads, &now)) {
12510×
        return Err(Held::NotPassed);
12521×
    }
12531×
    let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?;
12541×
    let via = "Diffusion, both halves green";
12551×
    match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true, None).await {
1256
        Ok(_) => {
12571×
            tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number);
12581×
            Ok(bump.number)
1259
        }
12600×
        Err(crate::web::proposals::Landing::NotFound) => Err(Held::NoBump),
12610×
        Err(crate::web::proposals::Landing::Refused(why)) => {
1262
            // Said once, so the timeline explains why a green bump waits.
12630×
            let said = db::proposals::events(&app.db, bump.id)
12640×
                .await
12650×
                .unwrap_or_default()
12660×
                .iter()
12670×
                .any(|e| e.event == "not landed by itself" && e.detail == why);
12680×
            if !said {
12690×
                db::proposals::add_event(&app.db, bump.id, ACCOUNT, "not landed by itself", &why).await.ok();
12700×
            }
12710×
            Err(Held::Refused(why))
1272
        }
1273
    }
12748×
}
1275
1276
/// [`auto_land`] in the background, logged. Boxed as `Send`: a landing
1277
/// moves the target as a push would, which may bump and land again.
12787×
pub fn try_land(app: Shared, consumer: RepoRow, path: String) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
12797×
    Box::pin(async move {
1280
        // One landing of this pin at a time across processes (#275).
12817×
        let Ok(_pin) = crate::leader::exclusive(&app.db, &format!("diffusion:{}:{path}", consumer.id)).await else { return };
12827×
        match auto_land(&app, &consumer, &path).await {
12836×
            Ok(_) | Err(Held::NoBump | Held::Manual) => {}
12841×
            Err(why) => tracing::info!("diffusion: {}/{} {path} waits for a person: {why:?}", consumer.owner, consumer.name),
1285
        }
12867×
    })
12877×
}
1288
1289
/// Try every open bump of `consumer` (its setting turned to `auto`).
12901×
pub async fn land_ready(app: &Shared, consumer: &RepoRow) {
12911×
    let paths: Vec<String> = sqlx::query_scalar("SELECT DISTINCT path FROM diffusion_pins WHERE consumer_id = $1")
12921×
        .bind(consumer.id)
12931×
        .fetch_all(&app.db)
12941×
        .await
12951×
        .unwrap_or_default();
12961×
    for path in paths {
12971×
        tokio::spawn(try_land(app.clone(), consumer.clone(), path));
12981×
    }
12991×
}
1300
1301
/// Whether `upstream` has no checks of its own: Flow is off for it, or
1302
/// its default bookmark has never had a run. Its moves are then
1303
/// "unverified" (amber), never green.
1304334×
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
1305334×
    if !crate::flow::enabled(app, upstream) {
1306126×
        return true;
1307208×
    }
1308208×
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = $1 AND bookmark = $2")
1309208×
        .bind(upstream.id)
1310208×
        .bind(&upstream.default_bookmark)
1311208×
        .fetch_one(&app.db)
1312208×
        .await
1313208×
        .unwrap_or(0);
1314208×
    runs == 0
1315334×
}
1316
1317
/// The repository on this hub with Oxygen id `id` (hex).
131823×
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
131942×
    for row in db::repos::list(&app.db).await.ok()? {
132042×
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
132142×
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
132223×
            return Some(row);
132319×
        }
1324
    }
13250×
    None
132623×
}
1327
1328
/// The newest state of `upstream`'s default bookmark whose run passed.
132910×
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
133010×
    let heads: String = sqlx::query_scalar(
133110×
        "SELECT heads FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND status = 'passed' ORDER BY id DESC LIMIT 1",
133210×
    )
133310×
    .bind(upstream.id)
133410×
    .bind(&upstream.default_bookmark)
133510×
    .fetch_optional(&app.db)
133610×
    .await
133710×
    .ok()
133810×
    .flatten()?;
13398×
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
13408×
    (!h.is_empty()).then_some(h)
134110×
}
1342
1343
/// Bring `consumer` up to date with the upstreams it pins, at once: when
1344
/// it turns Diffusion on, it is bumped to each upstream's newest state
1345
/// that passed (or simply its newest, when it follows every move).
13466×
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
13476×
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = $1")
13486×
        .bind(consumer.id)
13496×
        .fetch_all(&app.db)
13506×
        .await
13516×
        .unwrap_or_default();
13526×
    for id in upstreams {
13536×
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
13546×
        if let Some(h) = last_green(app, &upstream).await {
13554×
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
13562×
        }
13576×
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
13586×
        let b = upstream.default_bookmark.clone();
13596×
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
13606×
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
13610×
        }
1362
    }
13636×
}
1364
1365
/// When a consumer takes an upstream's moves (§4.2).
1366
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
1367
#[serde(rename_all = "lowercase")]
1368
pub enum Follow {
1369
    Off,
1370
    /// When the upstream state's checks pass.
1371
    Green,
1372
    /// On every move.
1373
    Any,
1374
}
1375
1376
impl Follow {
137780×
    pub fn parse(s: &str) -> Option<Follow> {
137880×
        match s.trim() {
137980×
            "off" => Some(Follow::Off),
138068×
            "green" => Some(Follow::Green),
138123×
            "any" => Some(Follow::Any),
138213×
            _ => None,
1383
        }
138480×
    }
1385
13862×
    pub fn as_str(self) -> &'static str {
13872×
        match self {
13880×
            Follow::Off => "off",
13892×
            Follow::Green => "green",
13900×
            Follow::Any => "any",
1391
        }
13922×
    }
1393
}
1394
1395
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
1396
/// repository setting and the site's default, in that order.
1397
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1398
pub struct Rule {
1399
    pub follow: Follow,
1400
    /// Land a bump itself once both halves are green (`land = auto`, or
1401
    /// the repository's setting); otherwise it only proposes.
1402
    pub land_auto: bool,
1403
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
1404
    pub target: Option<String>,
1405
    /// Paths the consumer watches: upstream changes touching them are
1406
    /// flagged in a bump's changelog.
1407
    pub watch: Vec<String>,
1408
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
1409
    pub from: &'static str,
1410
}
1411
1412
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
1413
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
141447×
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
141547×
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
141647×
    let mut here = false;
141747×
    let mut lines = text.lines();
1418289×
    while let Some(line) = lines.next() {
1419242×
        let line = line.trim();
1420242×
        if line.is_empty() || line.starts_with('#') {
142152×
            continue;
1422190×
        }
1423190×
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
142454×
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
142554×
            continue;
1426136×
        }
1427136×
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
1428
        // A list may run over several lines (oxygenhub#200), as TOML allows:
1429
        // read on to its closing `]`.
1430111×
        let mut v = v.trim().to_string();
1431111×
        if v.starts_with('[') && !v.contains(']') {
14323×
            for more in lines.by_ref() {
14333×
                let more = more.trim();
14343×
                if more.starts_with('#') {
14351×
                    continue;
14362×
                }
14372×
                v.push_str(more);
14382×
                if more.contains(']') {
14391×
                    break;
14401×
                }
1441
            }
1442110×
        }
1443111×
        let v = v.as_str();
1444111×
        let unquoted = v.trim_matches('"').trim();
1445111×
        match k.trim() {
1446111×
            "follow" => follow = Follow::parse(unquoted),
1447101×
            "land" => land = Some(unquoted == "auto"),
144894×
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
144990×
            "watch" => {
14508×
                watch = v
14518×
                    .trim_start_matches('[')
14528×
                    .trim_end_matches(']')
14538×
                    .split(',')
145415×
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
145515×
                    .filter(|w| !w.is_empty())
14568×
                    .collect()
1457
            }
145882×
            _ => {}
1459
        }
1460
    }
146147×
    (follow, land, target, watch)
146247×
}
1463
1464
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
146565×
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
146665×
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
146765×
    let (follow, from) = match follow {
14687×
        Some(f) => (f, ".oxymodules"),
146958×
        None => match Follow::parse(&row.diffusion) {
147046×
            Some(f) => (f, "setting"),
147112×
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
1472
        },
1473
    };
1474
    // Landing by itself: the entry's word, else the setting; never by default.
147565×
    let land_auto = land.unwrap_or(row.diffusion_land == "auto");
147665×
    Rule { follow, land_auto, target, watch, from }
147765×
}
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
14965×
    fn row(diffusion: &str) -> RepoRow {
14975×
        RepoRow {
14985×
            id: 1,
14995×
            owner: "o".into(),
15005×
            name: "n".into(),
15015×
            description: String::new(),
15025×
            default_bookmark: "main".into(),
15035×
            visibility: "public".into(),
15045×
            created_ms: 0,
15055×
            updated_ms: 0,
15065×
            git_mirror: false,
15075×
            git_mirror_state: String::new(),
15085×
            forked_from: None,
15095×
            topics: String::new(),
15105×
            flow: String::new(),
15115×
            diffusion: diffusion.into(),
15125×
            ..Default::default()
15135×
        }
15145×
    }
1515
1516
    #[test]
15171×
    fn landing_by_itself_is_chosen_per_repository_and_per_entry() {
15181×
        let mut r = row("green");
15191×
        assert!(!rule("off", &r, None, "rstar-api").land_auto, "never by default");
15201×
        r.diffusion_land = "auto".into();
15211×
        assert!(rule("off", &r, None, "rstar-api").land_auto, "the setting");
15221×
        assert!(!rule("off", &r, Some(MODULES), "rstar-api").land_auto, "the entry's `land = propose` wins");
15231×
        assert!(rule("off", &r, Some(MODULES), "other").land_auto, "an entry without `land` takes the setting");
15241×
        r.diffusion_land = String::new();
15251×
        let auto = MODULES.replace(r#"land = "propose""#, "land = auto");
15261×
        assert!(rule("off", &r, Some(&auto), "rstar-api").land_auto);
15271×
    }
1528
1529
    #[test]
15301×
    fn watch_patterns_match_paths() {
15311×
        assert!(watched("src/x16/irq*", "src/x16/irq.s"));
15321×
        assert!(watched("src/x16/irq*", "src/x16/irq_wait.c"));
15331×
        assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment");
15341×
        assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c"));
15351×
        assert!(watched("src/x16", "src/x16/bank.s"), "a directory");
15361×
        assert!(!watched("src/x16", "src/x160/bank.s"));
15371×
        assert!(watched("include/r?m.h", "include/rim.h"));
15381×
        assert!(!watched("", "anything"));
15391×
    }
1540
1541
    #[test]
15421×
    fn oxymodules_keys_are_read_per_entry() {
15431×
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
15441×
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
15451×
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
15461×
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
15471×
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
15481×
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
1549
        // #200: a list over several lines, as in trailblazers, comments
1550
        // and the keys after it included.
15511×
        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";
15521×
        let (f, _, _, watch) = module_keys(multi, "rstar-api");
15531×
        assert_eq!(watch, vec!["src/x16/rbm*", "src/x16/rdm.c", "src/mega65/rsm.c"]);
15541×
        assert_eq!(f, Some(Follow::Green), "the key after the list is still read");
15551×
    }
1556
1557
    #[test]
15581×
    fn oxymodules_wins_then_the_setting_then_the_site() {
15591×
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
15601×
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
15611×
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
15621×
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
15631×
        let r = rule("green", &row(""), None, "rstar-api");
15641×
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
15651×
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
15661×
    }
1567
}
1568
1569
#[cfg(test)]
1570
mod message_tests {
1571
    use super::bump_message;
1572
1573
    #[test]
15741×
    fn a_bump_of_many_heads_has_a_short_title_and_the_whole_address_below() {
157520×
        let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect();
15761×
        let m = bump_message("rstar-api", "doomsdayone/rstar-api", &heads);
15771×
        let (title, body) = m.split_once("\n\n").unwrap();
15781×
        assert_eq!(title, "Diffusion: move rstar-api to doomsdayone/rstar-api 00a6c9d4746a (+19 heads)");
15791×
        assert_eq!(body, format!("Pins doomsdayone/rstar-api at {}", heads.join("+")));
15801×
        assert_eq!(bump_message("api", "andy/api", &heads[..1]), "Diffusion: move api to andy/api 00a6c9d4746a");
15811×
    }
1582
}
1583
1584
#[cfg(test)]
1585
mod pin_tests {
1586
    use super::pin_bookmark;
1587
    use oxy_core::hash::ObjectRef;
1588
1589
    #[test]
15901×
    fn a_bump_pins_the_upstreams_default_bookmark_unless_it_keeps_other_changes() {
15911×
        let (a, b) = (ObjectRef([1; 32]), ObjectRef([2; 32]));
1592
        // R*'s proposal had landed on main: the move is to main's heads,
1593
        // so the pin names main, not the proposal's bookmark (#332).
15941×
        assert_eq!(pin_bookmark(&[a], &[a], "main", "x16-tilemap-double".into()), "main");
15951×
        assert_eq!(pin_bookmark(&[b, a], &[a, b], "main", "feature".into()), "main", "in any order");
1596
        // The pin's own changes are kept beside main's: still the bookmark
1597
        // that has them.
15981×
        assert_eq!(pin_bookmark(&[a, b], &[a], "main", "feature".into()), "feature");
15991×
    }
1600
}