OXYGENHUB
# 2cbf6f348595 oxygenhub / crates / hub / src / diffusion.rs
56.5 KiB · Rust · last changed Diffusion reads multi-line lists; assistants say why there is no answer 20 days ago source blame history raw
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(
26
        "SELECT target FROM proposals WHERE repo_id = ? AND state = 'landed' AND landed_ms >= ?
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]) {
51
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
52
    let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await };
53
    let now = db::now_ms();
54
    // The upstream's repository on this hub, when it is one.
55
    let mut upstreams = Vec::new();
56
    for (_, id, _) in &pins {
57
        upstreams.push(repo_by_oxy_id(app, &hex::encode(id)).await.map(|r| r.id));
58
    }
59
    let Ok(mut tx) = app.db.begin().await else { return };
60
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
61
        .bind(row.id)
62
        .bind(bookmark)
63
        .execute(&mut *tx)
64
        .await
65
        .is_ok();
66
    for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) {
67
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
68
        ok &= sqlx::query(
69
            "INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms, upstream_repo_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
70
        )
71
        .bind(row.id)
72
        .bind(bookmark)
73
        .bind(path)
74
        .bind(hex::encode(id))
75
        .bind(heads.join("+"))
76
        .bind(now)
77
        .bind(upstream_row)
78
        .execute(&mut *tx)
79
        .await
80
        .is_ok();
81
        if let Some(u) = upstream_row {
82
            mark(app, row.id);
83
            mark(app, *u);
84
        }
85
    }
86
    if ok {
87
        tx.commit().await.ok();
88
    }
89
}
90
91
/// After a push: index the pins of each moved bookmark that is followed.
92
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
93
    let followed = followed_bookmarks(&app.db, row).await;
94
    for (bookmark, _, heads) in moved {
95
        if followed.contains(bookmark) {
96
            record(app, row, bookmark, heads).await;
97
        }
98
    }
99
}
100
101
static INVOLVED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
102
103
fn key(app: &Shared, repo_id: i64) -> String {
104
    format!("{}#{repo_id}", app.repos.root().display())
105
}
106
107
fn mark(app: &Shared, repo_id: i64) {
108
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
109
}
110
111
/// Whether a repository takes part in Diffusion, as an upstream or a
112
/// consumer on this hub: its Diffusion tab shows (#150).
113
pub fn involved(app: &Shared, repo_id: i64) -> bool {
114
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
115
}
116
117
/// Index every repository's followed bookmarks, for a hub that had no
118
/// index before (run once at start, in the background).
119
pub async fn backfill(app: &Shared) {
120
    let Ok(rows) = db::repos::list(&app.db).await else { return };
121
    for row in rows {
122
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
123
        for bookmark in followed_bookmarks(&app.db, &row).await {
124
            let b = bookmark.clone();
125
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
126
            if let Ok(heads) = heads {
127
                record(app, &row, &bookmark, &heads).await;
128
            }
129
        }
130
    }
131
}
132
133
/// A bookmark's heads as the repository has them now (not a cached
134
/// list, which a push refreshes a moment later); empty when it has none.
135
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
136
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
137
}
138
139
/// One consumer of an upstream, as an upstream's page and API show it.
140
#[derive(Clone, Debug, Serialize)]
141
pub struct Consumer {
142
    pub owner: String,
143
    pub name: String,
144
    pub bookmark: String,
145
    pub path: String,
146
    /// The upstream heads it pins, hex.
147
    pub pinned: Vec<String>,
148
    /// Upstream changes on its default bookmark the pin lacks; `None` when
149
    /// the hub cannot tell (the pinned changes are not in the upstream).
150
    pub behind: Option<usize>,
151
    /// How it follows the upstream (from its setting, or the site's).
152
    pub follow: Follow,
153
    /// Its open bump proposal's number, if it has one.
154
    pub bump: Option<i64>,
155
    /// The newest run of its bump bookmark: number and status.
156
    pub run: Option<(i64, String)>,
157
    /// Its last landed bump: number and when.
158
    pub landed: Option<(i64, i64)>,
159
    /// What the views show (#150).
160
    pub colour: Colour,
161
    /// The pill's two halves: the upstream, then this consumer.
162
    pub upstream: Half,
163
    pub downstream: Half,
164
}
165
166
impl Consumer {
167
    /// As the API gives it (oxygenhub#153), with its upstream's name.
168
    pub fn api(&self, upstream: &str) -> hub_api::DiffusionPin {
169
        let half = |h: &Half| hub_api::DiffusionHalf {
170
            tone: h.class.trim_start_matches("df-").to_string(),
171
            word: h.word.to_string(),
172
            title: h.title.clone(),
173
        };
174
        hub_api::DiffusionPin {
175
            upstream: upstream.to_string(),
176
            consumer: format!("{}/{}", self.owner, self.name),
177
            path: self.path.clone(),
178
            bookmark: self.bookmark.clone(),
179
            behind: self.behind.map(|n| n as u64),
180
            follow: self.follow.as_str().to_string(),
181
            bump: self.bump,
182
            run: self.run.as_ref().map(|r| r.0),
183
            run_status: self.run.as_ref().map(|r| r.1.clone()),
184
            upstream_state: half(&self.upstream),
185
            consumer_state: half(&self.downstream),
186
        }
187
    }
188
}
189
190
/// One half of a Diffusion pill: a colour class and a word (#150).
191
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
192
pub struct Half {
193
    pub class: &'static str,
194
    pub word: &'static str,
195
    pub title: String,
196
}
197
198
impl Half {
199
    /// The upstream's half: was the upstream itself tested?
200
    pub fn upstream(unverified: bool, run: Option<&str>, slug: &str) -> Half {
201
        let (class, word, title) = match (unverified, run) {
202
            (true, _) => ("df-amber", "untested", format!("{slug} has no checks of its own: nothing has tested it")),
203
            (false, Some("passed")) => ("df-green", "checked", format!("{slug}'s newest run passed")),
204
            (false, Some("failed" | "error" | "cancelled")) => ("df-red", "failing", format!("{slug}'s newest run failed")),
205
            (false, _) => ("df-grey", "testing", format!("{slug}'s newest run has not finished")),
206
        };
207
        Half { class, word, title }
208
    }
209
210
    /// The consumer's half: does it pass with the upstream?
211
    pub fn downstream(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, name: &str) -> Half {
212
        let (class, word, title) = if follow == Follow::Off {
213
            ("df-off", "not following", format!("{name} does not follow it"))
214
        } else {
215
            match (bump, run) {
216
                (Some(_), Some("passed")) => ("df-green", "passes", format!("{name}'s run on the bump passed")),
217
                (Some(_), Some("failed" | "error" | "cancelled")) => ("df-red", "fails", format!("{name}'s run on the bump failed")),
218
                (Some(_), Some(_)) => ("df-grey", "testing", format!("{name}'s run on the bump has not finished")),
219
                (Some(_), None) => ("df-grey", "waiting", format!("{name}'s bump has no run yet")),
220
                (None, _) if behind == Some(0) => ("df-green", "up to date", format!("{name} pins the newest state")),
221
                (None, _) => ("df-grey", "waiting", format!("{name} is behind; its bump has not been made yet")),
222
            }
223
        };
224
        Half { class, word, title }
225
    }
226
}
227
228
/// A consumer's Diffusion status at a glance (#150, Andy's colours):
229
/// green when the upstream was checked and the consumer's run on the bump
230
/// passed; amber when the run passed but the upstream has no checks; red
231
/// when the run failed; grey while it runs; up to date when there is
232
/// nothing to bump; off when it does not follow.
233
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
234
#[serde(rename_all = "kebab-case")]
235
pub enum Colour {
236
    Green,
237
    Amber,
238
    Red,
239
    Grey,
240
    UpToDate,
241
    Off,
242
}
243
244
impl Colour {
245
    pub fn class(&self) -> &'static str {
246
        match self {
247
            Colour::Green => "df-green",
248
            Colour::Amber => "df-amber",
249
            Colour::Red => "df-red",
250
            Colour::Grey => "df-grey",
251
            Colour::UpToDate => "df-current",
252
            Colour::Off => "df-off",
253
        }
254
    }
255
256
    pub fn label(&self) -> &'static str {
257
        match self {
258
            Colour::Green => "green",
259
            Colour::Amber => "amber: upstream unverified",
260
            Colour::Red => "red: its run failed",
261
            Colour::Grey => "testing",
262
            Colour::UpToDate => "up to date",
263
            Colour::Off => "not following",
264
        }
265
    }
266
267
    /// The colour from what is known of a consumer.
268
    pub fn of(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, unverified: bool) -> Colour {
269
        if follow == Follow::Off {
270
            return Colour::Off;
271
        }
272
        match (bump, run) {
273
            (Some(_), Some("passed")) if unverified => Colour::Amber,
274
            (Some(_), Some("passed")) => Colour::Green,
275
            (Some(_), Some("failed" | "error" | "cancelled")) => Colour::Red,
276
            (Some(_), _) => Colour::Grey,
277
            (None, _) if behind == Some(0) => Colour::UpToDate,
278
            (None, _) => Colour::Grey,
279
        }
280
    }
281
}
282
283
/// The consumers of `row` that `account` may read, with how far behind
284
/// the upstream's default bookmark each pin is.
285
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
286
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
287
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
288
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
289
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = ? ORDER BY consumer_id, bookmark, path",
290
    )
291
    .bind(hex::encode(id))
292
    .fetch_all(&app.db)
293
    .await
294
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
295
    let mut out = Vec::new();
296
    let mut pinned_sets = Vec::new();
297
    let unverified = unverified(app, row).await;
298
    for (consumer_id, bookmark, path, heads) in found {
299
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
300
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
301
            continue;
302
        }
303
        // One row per consumer and subrepo: the bookmark its work lands on.
304
        if bookmark != target(&app.db, &c).await {
305
            continue;
306
        }
307
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
308
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
309
        let bump_bm = bump_bookmark(&path);
310
        let mine: Vec<db::proposals::ProposalRow> = db::proposals::list(&app.db, c.id, &[])
311
            .await
312
            .unwrap_or_default()
313
            .into_iter()
314
            .filter(|p| p.source_bookmark == bump_bm)
315
            .collect();
316
        let bump = mine.iter().find(|p| p.state == "open" || p.state == "draft").map(|p| p.number);
317
        let landed = mine.iter().filter(|p| p.state == "landed").max_by_key(|p| p.landed_ms).map(|p| (p.number, p.landed_ms));
318
        let run: Option<(i64, String)> =
319
            sqlx::query_as("SELECT number, status FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
320
                .bind(c.id)
321
                .bind(&bump_bm)
322
                .fetch_optional(&app.db)
323
                .await
324
                .ok()
325
                .flatten();
326
        let follow = rule(&app.config.diffusion.follow, &c, None, &path).follow;
327
        out.push(Consumer {
328
            owner: c.owner,
329
            name: c.name,
330
            bookmark,
331
            path,
332
            pinned,
333
            behind: None,
334
            follow,
335
            bump,
336
            run,
337
            landed,
338
            colour: Colour::Off,
339
            upstream: Half { class: "df-grey", word: "", title: String::new() },
340
            downstream: Half { class: "df-grey", word: "", title: String::new() },
341
        });
342
    }
343
    if out.is_empty() {
344
        return Ok(out);
345
    }
346
    let default = row.default_bookmark.clone();
347
    let behind = app
348
        .repos
349
        .read(&name, move |r, cache| {
350
            let tip = heads_of(r, &default)?;
351
            let tip = cache.summary(r, &tip)?;
352
            Ok(pinned_sets
353
                .iter()
354
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
355
                .collect::<Vec<_>>())
356
        })
357
        .await?;
358
    let up_run: Option<String> =
359
        sqlx::query_scalar("SELECT status FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
360
            .bind(row.id)
361
            .bind(&row.default_bookmark)
362
            .fetch_optional(&app.db)
363
            .await
364
            .ok()
365
            .flatten();
366
    let slug = format!("{}/{}", row.owner, row.name);
367
    for (c, b) in out.iter_mut().zip(behind) {
368
        c.behind = b;
369
        let run = c.run.as_ref().map(|r| r.1.clone());
370
        c.colour = Colour::of(c.follow, b, c.bump, run.as_deref(), unverified);
371
        c.upstream = Half::upstream(unverified, up_run.as_deref(), &slug);
372
        c.downstream = Half::downstream(c.follow, b, c.bump, run.as_deref(), &format!("{}/{}", c.owner, c.name));
373
    }
374
    Ok(out)
375
}
376
377
/// What `consumer` follows, as its Follows panel shows: each upstream on
378
/// this hub it pins, with its status there.
379
pub async fn follows(app: &Shared, consumer: &RepoRow, account: Option<&Account>) -> Vec<(RepoRow, Consumer)> {
380
    let ups: Vec<i64> =
381
        sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id IS NOT NULL")
382
            .bind(consumer.id)
383
            .fetch_all(&app.db)
384
            .await
385
            .unwrap_or_default();
386
    let mut out = Vec::new();
387
    for id in ups {
388
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
389
        if !roles::allows(roles::role_for(&app.db, account, &up).await.ok().flatten(), crate::auth::Access::Read) {
390
            continue;
391
        }
392
        for c in consumers(app, &up, account).await.unwrap_or_default() {
393
            if c.owner == consumer.owner && c.name == consumer.name {
394
                out.push((up.clone(), c));
395
            }
396
        }
397
    }
398
    out
399
}
400
401
/// Every upstream on this hub with its consumers, for Admin › Diffusion.
402
pub async fn everything(app: &Shared, account: Option<&Account>) -> Vec<(RepoRow, Vec<Consumer>, bool)> {
403
    let ups: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL")
404
        .fetch_all(&app.db)
405
        .await
406
        .unwrap_or_default();
407
    let mut out = Vec::new();
408
    for id in ups {
409
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
410
        let list = consumers(app, &up, account).await.unwrap_or_default();
411
        let unverified = unverified(app, &up).await;
412
        out.push((up, list, unverified));
413
    }
414
    out
415
}
416
417
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
418
pub fn bump_bookmark(path: &str) -> String {
419
    format!("diffusion/{}", path.trim_matches('/'))
420
}
421
422
/// What a bump did.
423
#[derive(Clone, Debug, PartialEq, Eq)]
424
pub enum Bumped {
425
    /// The bump bookmark now holds the target plus one change moving the pin.
426
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash>, state: String },
427
    /// The pin already holds the upstream state.
428
    UpToDate,
429
    /// The bump bookmark already moves the pin to that state.
430
    Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> },
431
    /// The pin names changes the upstream does not hold: not followed.
432
    Unknown,
433
    /// Taking the upstream's state with the pin's own extra changes would
434
    /// conflict.
435
    Conflicts,
436
    /// The consumer has no Oxygen pin of that upstream at `path` on its
437
    /// target bookmark.
438
    NoPin,
439
}
440
441
/// Where a consumer's pin `old` moves when the upstream reaches `new`,
442
/// worked out in the upstream (§4.3).
443
#[derive(Clone, Debug, PartialEq, Eq)]
444
pub enum Plan {
445
    /// To these heads, which add this many changes.
446
    To(Vec<ChangeHash>, usize),
447
    /// Nothing to add.
448
    UpToDate,
449
    /// The pin names changes the upstream does not hold (rewritten, or
450
    /// from elsewhere): not followed.
451
    Unknown,
452
    /// Taking `new` with the pin's own extra changes would conflict.
453
    Conflicts,
454
}
455
456
/// The pin `old` moved to the upstream's `new`. When the pin has changes
457
/// `new` lacks (say, from an upstream proposal not landed yet), they are
458
/// kept: changes that do not touch each other combine, so the move is to
459
/// both, never dropping what the consumer already relies on.
460
pub async fn plan(app: &Shared, upstream: &RepoName, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Result<Plan, crate::repos::RepoError> {
461
    app.repos
462
        .read(upstream, move |r, cache| {
463
            let Ok(o) = cache.summary(r, &old) else { return Ok(Plan::Unknown) };
464
            let n = cache.summary(r, &new)?;
465
            if o.changes.iter().all(|c| n.changes.contains(c)) {
466
                let added = n.changes.len() - o.changes.len();
467
                return Ok(if added == 0 { Plan::UpToDate } else { Plan::To(new, added) });
468
            }
469
            let mut both = new.clone();
470
            both.extend(old.iter().copied());
471
            let heads = r.load_state(&both)?.heads();
472
            let b = cache.summary(r, &heads)?;
473
            if b.conflicted && !o.conflicted {
474
                return Ok(Plan::Conflicts);
475
            }
476
            let added = b.changes.len() - o.changes.len();
477
            Ok(if added == 0 { Plan::UpToDate } else { Plan::To(heads, added) })
478
        })
479
        .await
480
}
481
482
/// Move `consumer`'s pin of `upstream` at `path` to `new_heads`, on a bump
483
/// bookmark built from `target` (§4.3): one change, signed with the hub's
484
/// key, on the target's heads. The upstream decides forwardness.
485
pub async fn bump(
486
    app: &Shared,
487
    consumer: &RepoRow,
488
    target: &str,
489
    path: &str,
490
    upstream: &RepoRow,
491
    new_heads: Vec<ChangeHash>,
492
) -> Result<Bumped, crate::repos::RepoError> {
493
    use oxy_core::identity::Domain;
494
    use oxy_core::subrepo::Pin;
495
    let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name))
496
    else {
497
        return Ok(Bumped::NoPin);
498
    };
499
    let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?;
500
    // The consumer's current pin at `path` on its target, and on its bump
501
    // bookmark if it has one.
502
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
503
    let (current, bumped) = app
504
        .repos
505
        .read(&cname, move |r, cache| {
506
            let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> {
507
                let heads = heads_of(r, bookmark)?;
508
                if heads.is_empty() {
509
                    return Ok(None);
510
                }
511
                let loaded = cache.load(r, &heads)?;
512
                let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) };
513
                let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) };
514
                let Some(b) = blobs.first() else { return Ok(None) };
515
                Ok(Pin::decode(&r.read_blob(b)?).ok())
516
            };
517
            Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?))
518
        })
519
        .await?;
520
    let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) };
521
    if repo != upstream_id {
522
        return Ok(Bumped::NoPin);
523
    }
524
    let new_heads = match plan(app, &uname, old.clone(), new_heads.clone()).await? {
525
        Plan::To(heads, _) => heads,
526
        Plan::UpToDate => return Ok(Bumped::UpToDate),
527
        Plan::Unknown => return Ok(Bumped::Unknown),
528
        Plan::Conflicts => return Ok(Bumped::Conflicts),
529
    };
530
    if let Some(Pin::Oxy { heads: b, .. }) = &bumped {
531
        let (mut b, mut n) = (b.clone(), new_heads.clone());
532
        b.sort();
533
        n.sort();
534
        if b == n {
535
            return Ok(Bumped::Already { from: old, to: new_heads });
536
        }
537
    }
538
    let (key, secret) =
539
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?;
540
    let identity = app.hub_key.identity.clone();
541
    let upstream_label = format!("{}/{}", upstream.owner, upstream.name);
542
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
543
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
544
    let old = old.clone();
545
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
546
    let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+"));
547
    app.repos
548
        .write(&cname, move |r, _| {
549
            let base = heads_of(r, &t)?;
550
            let change = r.build_pin_change(&base, &p, &pin, &message, key)?;
551
            let hash = change.hash();
552
            let sig = secret.sign(Domain::Change, &key, &hash);
553
            let mut state = r.load_state(&base)?;
554
            state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
555
            let mut candidates = base.clone();
556
            candidates.push(hash);
557
            let heads = state.maximal(&candidates);
558
            let bookmark = bump_bookmark(&p);
559
            // The bump bookmark is the hub's own and is rebuilt on the
560
            // target each time, so the bump it replaces may go.
561
            let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?;
562
            update.force = true;
563
            let state = update.new_state.to_hex();
564
            let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?;
565
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
566
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
567
                    "the bump was refused: {}",
568
                    refused.reason
569
                ))));
570
            }
571
            Ok(Bumped::Moved { bookmark, change: hash, heads, from: old, state })
572
        })
573
        .await
574
}
575
576
/// The account Diffusion's proposals are opened as.
577
pub const ACCOUNT: &str = "diffusion";
578
579
/// The `diffusion` account, made on first use with no way to sign in.
580
pub async fn account(pool: &Pool) -> Option<Account> {
581
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
582
        return Some(a);
583
    }
584
    db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok()
585
}
586
587
/// Whether `path` matches a `watch` pattern: `*` is any run of characters
588
/// within one path segment, `**` any run across segments, `?` one
589
/// character; a pattern without a wildcard also matches everything below
590
/// it, as a directory would.
591
pub fn watched(pattern: &str, path: &str) -> bool {
592
    fn m(p: &[u8], s: &[u8]) -> bool {
593
        match (p.first(), s.first()) {
594
            (None, None) => true,
595
            (Some(b'*'), _) if p.get(1) == Some(&b'*') => (0..=s.len()).any(|i| m(&p[2..], &s[i..])),
596
            (Some(b'*'), _) => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| m(&p[1..], &s[i..])),
597
            (Some(b'?'), Some(c)) if *c != b'/' => m(&p[1..], &s[1..]),
598
            (Some(a), Some(b)) if a == b => m(&p[1..], &s[1..]),
599
            _ => false,
600
        }
601
    }
602
    let p = pattern.trim().trim_matches('/');
603
    if p.is_empty() {
604
        return false;
605
    }
606
    m(p.as_bytes(), path.as_bytes()) || (!p.contains(['*', '?']) && path.starts_with(&format!("{p}/")))
607
}
608
609
/// What a bump brings: the upstream changes in `new` and not in `old`,
610
/// newest first, as Markdown lines linking to the upstream's change pages,
611
/// with those touching a `watch` pattern flagged (and listed again).
612
pub struct Changelog {
613
    pub lines: Vec<String>,
614
    pub watched: Vec<String>,
615
}
616
617
pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>, watch: Vec<String>) -> Changelog {
618
    let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Changelog { lines: Vec::new(), watched: Vec::new() } };
619
    let linked: std::collections::HashMap<String, String> =
620
        db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
621
    let base = format!("/{}/{}", upstream.owner, upstream.name);
622
    app.repos
623
        .read(&name, move |r, cache| {
624
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
625
            let mut added: Vec<(u64, ChangeHash, String, String, Vec<String>)> = Vec::new();
626
            for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) {
627
                let Ok(c) = r.store().get_change(h) else { continue };
628
                let key = c.author.to_hex();
629
                let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string());
630
                let first = c.message.lines().next().unwrap_or("").trim().to_string();
631
                // The watched paths it touches, when the game watches any.
632
                let hits: Vec<String> = if watch.is_empty() {
633
                    Vec::new()
634
                } else {
635
                    crate::repos::view::change(r, cache, h)
636
                        .map(|d| d.files.into_iter().map(|f| f.path).filter(|p| watch.iter().any(|w| watched(w, p))).collect())
637
                        .unwrap_or_default()
638
                };
639
                added.push((c.hlc.wall_ms, *h, first, who, hits));
640
            }
641
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
642
            let line = |h: &ChangeHash, msg: &str, who: &str, flag: bool| {
643
                format!(
644
                    "- {}[`{}`]({base}/change/{}) {} ({who})",
645
                    if flag { "⚑ " } else { "" },
646
                    &h.to_hex()[..12],
647
                    h.to_hex(),
648
                    msg.replace('\n', " ")
649
                )
650
            };
651
            let watched = added
652
                .iter()
653
                .filter(|a| !a.4.is_empty())
654
                .map(|(_, h, msg, who, hits)| {
655
                    format!(
656
                        "{} — touches {}",
657
                        line(h, msg, who, true),
658
                        hits.iter().map(|p| format!("`{p}`")).collect::<Vec<_>>().join(", ")
659
                    )
660
                })
661
                .collect();
662
            let lines = added.iter().map(|(_, h, msg, who, hits)| line(h, msg, who, !hits.is_empty())).collect();
663
            Ok(Changelog { lines, watched })
664
        })
665
        .await
666
        .unwrap_or(Changelog { lines: Vec::new(), watched: Vec::new() })
667
}
668
669
/// Why a bump was made, which its proposal says.
670
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
671
pub enum Why {
672
    /// The upstream's state passed its checks.
673
    Green,
674
    /// The upstream moved (the consumer follows every move).
675
    Moved,
676
    /// The upstream has no checks at all: nothing vouched for it, so the
677
    /// proposal is marked amber, `upstream unverified`.
678
    Unverified,
679
}
680
681
/// Open the bump proposal for `path`'s bump bookmark, or bring the open
682
/// one up to date: into `target`, by the `diffusion` account, labelled
683
/// `dependencies` (and `upstream unverified` while that is so), its body
684
/// the changelog from the pin `from` to `to`. Its number.
685
pub async fn propose(
686
    app: &Shared,
687
    consumer: &RepoRow,
688
    target: &str,
689
    path: &str,
690
    upstream: &RepoRow,
691
    (from, to): (Vec<ChangeHash>, Vec<ChangeHash>),
692
    (why, watch, lands): (Why, Vec<String>, bool),
693
) -> Option<i64> {
694
    use crate::db::proposals as pdb;
695
    let bookmark = bump_bookmark(path);
696
    let bookmark = bookmark.as_str();
697
    let me = account(&app.db).await?;
698
    let log = changelog(app, upstream, from, to.clone(), watch.clone()).await;
699
    let lines = log.lines;
700
    let flagged = if log.watched.is_empty() {
701
        String::new()
702
    } else {
703
        format!(
704
            "\n\n**Touches what you watch** ({}):\n\n{}",
705
            watch.iter().map(|w| format!("`{w}`")).collect::<Vec<_>>().join(", "),
706
            log.watched.join("\n")
707
        )
708
    };
709
    let slug = format!("{}/{}", upstream.owner, upstream.name);
710
    let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect();
711
    let ub = &upstream.default_bookmark;
712
    let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) };
713
    let title = format!("Diffusion: {path} to {slug} {ub} ({count})");
714
    let when = match why {
715
        Why::Green => "at its newest state whose checks passed",
716
        Why::Moved => "as it is now",
717
        Why::Unverified => "as it is now",
718
    };
719
    let amber = if why == Why::Unverified {
720
        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.")
721
    } else {
722
        String::new()
723
    };
724
    let body = format!(
725
        "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). {}",
726
        crate::web::state_label(&short.join("+")),
727
        lines.join("\n"),
728
        if lands {
729
            "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."
730
        } else {
731
            "It never lands by itself."
732
        }
733
    );
734
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
735
    let (number, id) = match open {
736
        Some(p) => {
737
            if p.title != title || p.body != body {
738
                pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
739
                pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &crate::web::state_label(&short.join("+"))).await.ok();
740
            }
741
            (p.number, p.id)
742
        }
743
        None => {
744
            let p = pdb::create(
745
                &app.db,
746
                pdb::NewProposal {
747
                    repo_id: consumer.id,
748
                    title: &title,
749
                    body: &body,
750
                    author_id: Some(me.id),
751
                    author: ACCOUNT,
752
                    draft: false,
753
                    target,
754
                    source_kind: "bookmark",
755
                    source_repo_id: None,
756
                    source_bookmark: bookmark,
757
                    source_changes: "",
758
                },
759
            )
760
            .await
761
            .ok()?;
762
            pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok();
763
            db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok();
764
            // Told as a proposal opened on the web is (oxygenhub#183).
765
            crate::notify::deliver(
766
                app,
767
                crate::notify::Event {
768
                    kind: "proposal.opened",
769
                    title: format!("{}/{}#{}: {}", consumer.owner, consumer.name, p.number, p.title),
770
                    url: format!("/{}/{}/proposal/{}", consumer.owner, consumer.name, p.number),
771
                    actor: &me,
772
                    repo_id: consumer.id,
773
                    thread: Some(("proposal", p.id)),
774
                    mention_text: "",
775
                    direct: vec![],
776
                },
777
            )
778
            .await;
779
            if let Ok(label) =
780
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
781
            {
782
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
783
            }
784
            (p.number, p.id)
785
        }
786
    };
787
    // Amber while the upstream has no checks; cleared by a green bump.
788
    let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten();
789
    match (why == Why::Unverified, unverified) {
790
        (true, l) => {
791
            let l = match l {
792
                Some(l) => Some(l),
793
                None => db::issues::upsert_label(
794
                    &app.db,
795
                    consumer.id,
796
                    "upstream unverified",
797
                    "#e3a008",
798
                    "The upstream has no checks (Diffusion)",
799
                )
800
                .await
801
                .ok(),
802
            };
803
            if let Some(l) = l {
804
                db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok();
805
            }
806
        }
807
        (false, Some(l)) => {
808
            db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok();
809
        }
810
        (false, None) => {}
811
    }
812
    Some(number)
813
}
814
815
/// Start a bump's pipeline, as a push of it would. Boxed as `Send`, which
816
/// breaks the cycle between a bump starting runs and a passing run
817
/// starting bumps that the compiler cannot see through.
818
fn start_runs(
819
    app: Shared,
820
    row: RepoRow,
821
    bookmark: String,
822
    state: String,
823
    heads: Vec<ChangeHash>,
824
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
825
    Box::pin(async move { crate::flow::on_push(&app, &row, &bookmark, &state, &heads, ACCOUNT).await })
826
}
827
828
/// The text of `.oxymodules` on `bookmark` of `name`, if any.
829
async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> {
830
    let b = bookmark.to_string();
831
    app.repos
832
        .read(name, move |r, cache| {
833
            let heads = heads_of(r, &b)?;
834
            if heads.is_empty() {
835
                return Ok(None);
836
            }
837
            let loaded = cache.load(r, &heads)?;
838
            Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) {
839
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
840
                _ => None,
841
            })
842
        })
843
        .await
844
        .ok()
845
        .flatten()
846
}
847
848
/// The upstream `upstream` reached `heads` on its default bookmark: by a
849
/// push (`green` false) or a passing run of that state (`green` true).
850
/// Bump and propose in every consumer that follows it that way (§4.3).
851
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
852
    propagate_to(app, upstream, heads, green, None).await
853
}
854
855
/// `propagate`, to one consumer only when `only` names it.
856
async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) {
857
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
858
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
859
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?")
860
        .bind(hex::encode(id))
861
        .fetch_all(&app.db)
862
        .await
863
        .unwrap_or_default();
864
    let unverified = unverified(app, upstream).await;
865
    for (consumer_id, indexed, path) in rows {
866
        if only.is_some_and(|o| o != consumer_id) {
867
            continue;
868
        }
869
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
870
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
871
        let learned = target(&app.db, &consumer).await;
872
        let modules = oxymodules(app, &cname, &learned).await;
873
        let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path);
874
        let target = r.target.clone().unwrap_or(learned);
875
        if indexed != target {
876
            continue;
877
        }
878
        // An upstream with no checks never turns green: its moves count
879
        // for green followers too, marked amber (unverified).
880
        let go = match r.follow {
881
            Follow::Off => false,
882
            Follow::Green => green || unverified,
883
            Follow::Any => !green,
884
        };
885
        let why = if green {
886
            Why::Green
887
        } else if unverified {
888
            Why::Unverified
889
        } else {
890
            Why::Moved
891
        };
892
        if !go {
893
            continue;
894
        }
895
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
896
            Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => {
897
                // Its pipeline runs as for a push, so the proposal is tested.
898
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
899
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await;
900
                tracing::info!(
901
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
902
                    consumer.owner,
903
                    consumer.name,
904
                    upstream.owner,
905
                    upstream.name
906
                );
907
            }
908
            Ok(Bumped::Already { from, to }) => {
909
                // Already moved: its proposal's text and labels are brought
910
                // up to date all the same.
911
                propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone(), r.land_auto)).await;
912
                // The upstream may just have turned green under a bump its
913
                // consumer already passed.
914
                if green && r.land_auto {
915
                    tokio::spawn(try_land(app.clone(), consumer.clone(), path.clone()));
916
                }
917
            }
918
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
919
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
920
        }
921
    }
922
}
923
924
/// Why a bump was not landed by itself (docs/diffusion.md §4.2).
925
#[derive(Debug, PartialEq, Eq)]
926
pub enum Held {
927
    /// No open bump at that path.
928
    NoBump,
929
    /// The consumer did not choose `land = auto`.
930
    Manual,
931
    /// The upstream has no checks: amber never lands by itself.
932
    Unverified,
933
    /// The upstream state the bump pins has no passing run.
934
    UpstreamUnchecked,
935
    /// The consumer's run on the bump as it is now has not passed.
936
    NotPassed,
937
    /// The landing was refused (required checks, approvals, the trust policy).
938
    Refused(String),
939
}
940
941
/// Land the open bump at `path` in `consumer` itself, as the `diffusion`
942
/// account, when the consumer chose `land = auto` (its `.oxymodules` entry,
943
/// else Settings › Diffusion) and both halves of its pill are green: the
944
/// upstream state it pins passed its own run (never amber: an upstream
945
/// with no checks always waits for a person), and the consumer's run on
946
/// the bump as it is now passed. It lands through the web's own path
947
/// (`land_as`), signed by the hub's key, so required checks, approvals and
948
/// the trust policy decide as for anyone; a refusal is noted once on the
949
/// proposal's timeline. The proposal's number when it landed.
950
pub async fn auto_land(app: &Shared, consumer: &RepoRow, path: &str) -> Result<i64, Held> {
951
    let path = path.trim_matches('/');
952
    let bm = bump_bookmark(path);
953
    let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { return Err(Held::NoBump) };
954
    let bump = db::proposals::list(&app.db, consumer.id, &["open"])
955
        .await
956
        .ok()
957
        .and_then(|ps| ps.into_iter().find(|p| p.source_bookmark == bm && p.author == ACCOUNT))
958
        .ok_or(Held::NoBump)?;
959
    let learned = target(&app.db, consumer).await;
960
    let modules = oxymodules(app, &cname, &learned).await;
961
    if !rule(&app.config.diffusion.follow, consumer, modules.as_deref(), path).land_auto {
962
        return Err(Held::Manual);
963
    }
964
    let upstream_id: Option<i64> = sqlx::query_scalar(
965
        "SELECT upstream_repo_id FROM diffusion_pins WHERE consumer_id = ? AND path = ? AND upstream_repo_id IS NOT NULL LIMIT 1",
966
    )
967
    .bind(consumer.id)
968
    .bind(path)
969
    .fetch_optional(&app.db)
970
    .await
971
    .ok()
972
    .flatten();
973
    let Some(upstream) = (match upstream_id {
974
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten(),
975
        None => None,
976
    }) else {
977
        return Err(Held::UpstreamUnchecked);
978
    };
979
    if unverified(app, &upstream).await {
980
        return Err(Held::Unverified);
981
    }
982
    // What the bump pins, and the bump bookmark's heads now.
983
    let (p, b) = (path.to_string(), bm.clone());
984
    let (pinned, now) = app
985
        .repos
986
        .read(&cname, move |r, cache| {
987
            let heads = heads_of(r, &b)?;
988
            if heads.is_empty() {
989
                return Ok((None, heads));
990
            }
991
            let loaded = cache.load(r, &heads)?;
992
            let pin = match loaded.snapshot.files.get(&p).map(|f| &f.content) {
993
                Some(oxy_core::state::FileContent::Opaque { blobs }) => match blobs.first() {
994
                    Some(blob) => oxy_core::subrepo::Pin::decode(&r.read_blob(blob)?).ok(),
995
                    None => None,
996
                },
997
                _ => None,
998
            };
999
            Ok((pin, heads))
1000
        })
1001
        .await
1002
        .map_err(|e| Held::Refused(e.to_string()))?;
1003
    let Some(oxy_core::subrepo::Pin::Oxy { heads: pinned, .. }) = pinned else { return Err(Held::UpstreamUnchecked) };
1004
    let same = |text: &str, want: &[ChangeHash]| {
1005
        let mut a: Vec<String> = text.split('+').filter(|h| !h.is_empty()).map(str::to_string).collect();
1006
        let mut b: Vec<String> = want.iter().map(|h| h.to_hex()).collect();
1007
        a.sort();
1008
        b.sort();
1009
        a == b
1010
    };
1011
    let upstream_runs: Vec<String> = sqlx::query_scalar(
1012
        "SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 100",
1013
    )
1014
    .bind(upstream.id)
1015
    .bind(&upstream.default_bookmark)
1016
    .fetch_all(&app.db)
1017
    .await
1018
    .unwrap_or_default();
1019
    if !upstream_runs.iter().any(|h| same(h, &pinned)) {
1020
        return Err(Held::UpstreamUnchecked);
1021
    }
1022
    let run: Option<(String, String)> =
1023
        sqlx::query_as("SELECT status, heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
1024
            .bind(consumer.id)
1025
            .bind(&bm)
1026
            .fetch_optional(&app.db)
1027
            .await
1028
            .ok()
1029
            .flatten();
1030
    if !run.is_some_and(|(status, heads)| status == "passed" && same(&heads, &now)) {
1031
        return Err(Held::NotPassed);
1032
    }
1033
    let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?;
1034
    let via = "Diffusion, both halves green";
1035
    match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true).await {
1036
        Ok(_) => {
1037
            tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number);
1038
            Ok(bump.number)
1039
        }
1040
        Err(crate::web::proposals::Landing::NotFound) => Err(Held::NoBump),
1041
        Err(crate::web::proposals::Landing::Refused(why)) => {
1042
            // Said once, so the timeline explains why a green bump waits.
1043
            let said = db::proposals::events(&app.db, bump.id)
1044
                .await
1045
                .unwrap_or_default()
1046
                .iter()
1047
                .any(|e| e.event == "not landed by itself" && e.detail == why);
1048
            if !said {
1049
                db::proposals::add_event(&app.db, bump.id, ACCOUNT, "not landed by itself", &why).await.ok();
1050
            }
1051
            Err(Held::Refused(why))
1052
        }
1053
    }
1054
}
1055
1056
/// [`auto_land`] in the background, logged. Boxed as `Send`: a landing
1057
/// moves the target as a push would, which may bump and land again.
1058
pub fn try_land(app: Shared, consumer: RepoRow, path: String) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
1059
    Box::pin(async move {
1060
        match auto_land(&app, &consumer, &path).await {
1061
            Ok(_) | Err(Held::NoBump | Held::Manual) => {}
1062
            Err(why) => tracing::info!("diffusion: {}/{} {path} waits for a person: {why:?}", consumer.owner, consumer.name),
1063
        }
1064
    })
1065
}
1066
1067
/// Try every open bump of `consumer` (its setting turned to `auto`).
1068
pub async fn land_ready(app: &Shared, consumer: &RepoRow) {
1069
    let paths: Vec<String> = sqlx::query_scalar("SELECT DISTINCT path FROM diffusion_pins WHERE consumer_id = ?")
1070
        .bind(consumer.id)
1071
        .fetch_all(&app.db)
1072
        .await
1073
        .unwrap_or_default();
1074
    for path in paths {
1075
        tokio::spawn(try_land(app.clone(), consumer.clone(), path));
1076
    }
1077
}
1078
1079
/// Whether `upstream` has no checks of its own: Flow is off for it, or
1080
/// its default bookmark has never had a run. Its moves are then
1081
/// "unverified" (amber), never green.
1082
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
1083
    if !crate::flow::enabled(app, upstream) {
1084
        return true;
1085
    }
1086
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND bookmark = ?")
1087
        .bind(upstream.id)
1088
        .bind(&upstream.default_bookmark)
1089
        .fetch_one(&app.db)
1090
        .await
1091
        .unwrap_or(0);
1092
    runs == 0
1093
}
1094
1095
/// The repository on this hub with Oxygen id `id` (hex).
1096
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
1097
    for row in db::repos::list(&app.db).await.ok()? {
1098
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
1099
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
1100
            return Some(row);
1101
        }
1102
    }
1103
    None
1104
}
1105
1106
/// The newest state of `upstream`'s default bookmark whose run passed.
1107
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
1108
    let heads: String =
1109
        sqlx::query_scalar("SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 1")
1110
            .bind(upstream.id)
1111
            .bind(&upstream.default_bookmark)
1112
            .fetch_optional(&app.db)
1113
            .await
1114
            .ok()
1115
            .flatten()?;
1116
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
1117
    (!h.is_empty()).then_some(h)
1118
}
1119
1120
/// Bring `consumer` up to date with the upstreams it pins, at once: when
1121
/// it turns Diffusion on, it is bumped to each upstream's newest state
1122
/// that passed (or simply its newest, when it follows every move).
1123
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
1124
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = ?")
1125
        .bind(consumer.id)
1126
        .fetch_all(&app.db)
1127
        .await
1128
        .unwrap_or_default();
1129
    for id in upstreams {
1130
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
1131
        if let Some(h) = last_green(app, &upstream).await {
1132
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
1133
        }
1134
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
1135
        let b = upstream.default_bookmark.clone();
1136
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
1137
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
1138
        }
1139
    }
1140
}
1141
1142
/// When a consumer takes an upstream's moves (§4.2).
1143
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
1144
#[serde(rename_all = "lowercase")]
1145
pub enum Follow {
1146
    Off,
1147
    /// When the upstream state's checks pass.
1148
    Green,
1149
    /// On every move.
1150
    Any,
1151
}
1152
1153
impl Follow {
1154
    pub fn parse(s: &str) -> Option<Follow> {
1155
        match s.trim() {
1156
            "off" => Some(Follow::Off),
1157
            "green" => Some(Follow::Green),
1158
            "any" => Some(Follow::Any),
1159
            _ => None,
1160
        }
1161
    }
1162
1163
    pub fn as_str(self) -> &'static str {
1164
        match self {
1165
            Follow::Off => "off",
1166
            Follow::Green => "green",
1167
            Follow::Any => "any",
1168
        }
1169
    }
1170
}
1171
1172
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
1173
/// repository setting and the site's default, in that order.
1174
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1175
pub struct Rule {
1176
    pub follow: Follow,
1177
    /// Land a bump itself once both halves are green (`land = auto`, or
1178
    /// the repository's setting); otherwise it only proposes.
1179
    pub land_auto: bool,
1180
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
1181
    pub target: Option<String>,
1182
    /// Paths the consumer watches: upstream changes touching them are
1183
    /// flagged in a bump's changelog.
1184
    pub watch: Vec<String>,
1185
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
1186
    pub from: &'static str,
1187
}
1188
1189
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
1190
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
1191
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
1192
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
1193
    let mut here = false;
1194
    let mut lines = text.lines();
1195
    while let Some(line) = lines.next() {
1196
        let line = line.trim();
1197
        if line.is_empty() || line.starts_with('#') {
1198
            continue;
1199
        }
1200
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1201
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
1202
            continue;
1203
        }
1204
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
1205
        // A list may run over several lines (oxygenhub#200), as TOML allows:
1206
        // read on to its closing `]`.
1207
        let mut v = v.trim().to_string();
1208
        if v.starts_with('[') && !v.contains(']') {
1209
            for more in lines.by_ref() {
1210
                let more = more.trim();
1211
                if more.starts_with('#') {
1212
                    continue;
1213
                }
1214
                v.push_str(more);
1215
                if more.contains(']') {
1216
                    break;
1217
                }
1218
            }
1219
        }
1220
        let v = v.as_str();
1221
        let unquoted = v.trim_matches('"').trim();
1222
        match k.trim() {
1223
            "follow" => follow = Follow::parse(unquoted),
1224
            "land" => land = Some(unquoted == "auto"),
1225
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
1226
            "watch" => {
1227
                watch = v
1228
                    .trim_start_matches('[')
1229
                    .trim_end_matches(']')
1230
                    .split(',')
1231
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
1232
                    .filter(|w| !w.is_empty())
1233
                    .collect()
1234
            }
1235
            _ => {}
1236
        }
1237
    }
1238
    (follow, land, target, watch)
1239
}
1240
1241
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
1242
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
1243
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
1244
    let (follow, from) = match follow {
1245
        Some(f) => (f, ".oxymodules"),
1246
        None => match Follow::parse(&row.diffusion) {
1247
            Some(f) => (f, "setting"),
1248
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
1249
        },
1250
    };
1251
    // Landing by itself: the entry's word, else the setting; never by default.
1252
    let land_auto = land.unwrap_or(row.diffusion_land == "auto");
1253
    Rule { follow, land_auto, target, watch, from }
1254
}
1255
1256
#[cfg(test)]
1257
mod tests {
1258
    use super::*;
1259
1260
    const MODULES: &str = r#"
1261
[rstar-api]
1262
url = https://oxygenhub.io/doomsdayone/rstar-api
1263
follow = green
1264
land = "propose"
1265
target = develop
1266
watch = ["src/x16/irq*", "src/x16/frame*"]
1267
1268
[other]
1269
url = https://example/other
1270
follow = any
1271
"#;
1272
1273
    fn row(diffusion: &str) -> RepoRow {
1274
        RepoRow {
1275
            id: 1,
1276
            owner: "o".into(),
1277
            name: "n".into(),
1278
            description: String::new(),
1279
            default_bookmark: "main".into(),
1280
            visibility: "public".into(),
1281
            created_ms: 0,
1282
            updated_ms: 0,
1283
            git_mirror: false,
1284
            git_mirror_state: String::new(),
1285
            forked_from: None,
1286
            topics: String::new(),
1287
            flow: String::new(),
1288
            diffusion: diffusion.into(),
1289
            ..Default::default()
1290
        }
1291
    }
1292
1293
    #[test]
1294
    fn landing_by_itself_is_chosen_per_repository_and_per_entry() {
1295
        let mut r = row("green");
1296
        assert!(!rule("off", &r, None, "rstar-api").land_auto, "never by default");
1297
        r.diffusion_land = "auto".into();
1298
        assert!(rule("off", &r, None, "rstar-api").land_auto, "the setting");
1299
        assert!(!rule("off", &r, Some(MODULES), "rstar-api").land_auto, "the entry's `land = propose` wins");
1300
        assert!(rule("off", &r, Some(MODULES), "other").land_auto, "an entry without `land` takes the setting");
1301
        r.diffusion_land = String::new();
1302
        let auto = MODULES.replace(r#"land = "propose""#, "land = auto");
1303
        assert!(rule("off", &r, Some(&auto), "rstar-api").land_auto);
1304
    }
1305
1306
    #[test]
1307
    fn watch_patterns_match_paths() {
1308
        assert!(watched("src/x16/irq*", "src/x16/irq.s"));
1309
        assert!(watched("src/x16/irq*", "src/x16/irq_wait.c"));
1310
        assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment");
1311
        assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c"));
1312
        assert!(watched("src/x16", "src/x16/bank.s"), "a directory");
1313
        assert!(!watched("src/x16", "src/x160/bank.s"));
1314
        assert!(watched("include/r?m.h", "include/rim.h"));
1315
        assert!(!watched("", "anything"));
1316
    }
1317
1318
    #[test]
1319
    fn oxymodules_keys_are_read_per_entry() {
1320
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
1321
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
1322
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
1323
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
1324
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
1325
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
1326
        // #200: a list over several lines, as in trailblazers, comments
1327
        // and the keys after it included.
1328
        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";
1329
        let (f, _, _, watch) = module_keys(multi, "rstar-api");
1330
        assert_eq!(watch, vec!["src/x16/rbm*", "src/x16/rdm.c", "src/mega65/rsm.c"]);
1331
        assert_eq!(f, Some(Follow::Green), "the key after the list is still read");
1332
    }
1333
1334
    #[test]
1335
    fn oxymodules_wins_then_the_setting_then_the_site() {
1336
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
1337
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
1338
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
1339
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
1340
        let r = rule("green", &row(""), None, "rstar-api");
1341
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
1342
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
1343
    }
1344
}