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