OXYGENHUB
# f4d6ef418a96 oxygenhub / crates / hub / src / diffusion.rs
54.8 KiB · Rust · last changed Diffusion lands a bump itself where a repository chose it 22 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(
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();
757195ad Andy McCall ↶ 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
    }
f677d627 Andy McCall ↶ 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();
757195ad Andy McCall ↶ 66
    for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) {
f677d627 Andy McCall ↶ 67
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
757195ad Andy McCall ↶ 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
        }
f677d627 Andy McCall ↶ 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
    }
757195ad Andy McCall ↶ 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));
f677d627 Andy McCall ↶ 109
}
110
757195ad Andy McCall ↶ 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
f677d627 Andy McCall ↶ 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>,
757195ad Andy McCall ↶ 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,
73ef9f76 Andy McCall ↶ 161
    /// The pill's two halves: the upstream, then this consumer.
162
    pub upstream: Half,
163
    pub downstream: Half,
757195ad Andy McCall ↶ 164
}
165
eb2a3502 Andy McCall ↶ 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
73ef9f76 Andy McCall ↶ 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
757195ad Andy McCall ↶ 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,
f677d627 Andy McCall ↶ 242
}
243
757195ad Andy McCall ↶ 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
f677d627 Andy McCall ↶ 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();
757195ad Andy McCall ↶ 297
    let unverified = unverified(app, row).await;
f677d627 Andy McCall ↶ 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) {
757195ad Andy McCall ↶ 301
            continue;
302
        }
303
        // One row per consumer and subrepo: the bookmark its work lands on.
304
        if bookmark != target(&app.db, &c).await {
f677d627 Andy McCall ↶ 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<_>>());
757195ad Andy McCall ↶ 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,
73ef9f76 Andy McCall ↶ 339
            upstream: Half { class: "df-grey", word: "", title: String::new() },
340
            downstream: Half { class: "df-grey", word: "", title: String::new() },
757195ad Andy McCall ↶ 341
        });
f677d627 Andy McCall ↶ 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?;
73ef9f76 Andy McCall ↶ 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);
f677d627 Andy McCall ↶ 367
    for (c, b) in out.iter_mut().zip(behind) {
368
        c.behind = b;
73ef9f76 Andy McCall ↶ 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));
f677d627 Andy McCall ↶ 373
    }
374
    Ok(out)
757195ad Andy McCall ↶ 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
f677d627 Andy McCall ↶ 415
}
e7158350 Andy McCall ↶ 416
1d634f69 Andy McCall ↶ 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.
f01cb865 Andy McCall ↶ 426
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash>, state: String },
1d634f69 Andy McCall ↶ 427
    /// The pin already holds the upstream state.
428
    UpToDate,
429
    /// The bump bookmark already moves the pin to that state.
9aaa88b2 Andy McCall ↶ 430
    Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> },
7a14bdae Andy McCall ↶ 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,
1d634f69 Andy McCall ↶ 436
    /// The consumer has no Oxygen pin of that upstream at `path` on its
437
    /// target bookmark.
438
    NoPin,
439
}
440
7a14bdae Andy McCall ↶ 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> {
1d634f69 Andy McCall ↶ 461
    app.repos
462
        .read(upstream, move |r, cache| {
7a14bdae Andy McCall ↶ 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);
1d634f69 Andy McCall ↶ 475
            }
7a14bdae Andy McCall ↶ 476
            let added = b.changes.len() - o.changes.len();
477
            Ok(if added == 0 { Plan::UpToDate } else { Plan::To(heads, added) })
1d634f69 Andy McCall ↶ 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) };
7a14bdae Andy McCall ↶ 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
    };
1d634f69 Andy McCall ↶ 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 {
9aaa88b2 Andy McCall ↶ 535
            return Ok(Bumped::Already { from: old, to: new_heads });
1d634f69 Andy McCall ↶ 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 };
a7e6e8e6 Andy McCall ↶ 544
    let old = old.clone();
1d634f69 Andy McCall ↶ 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;
f01cb865 Andy McCall ↶ 563
            let state = update.new_state.to_hex();
1d634f69 Andy McCall ↶ 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
            }
f01cb865 Andy McCall ↶ 571
            Ok(Bumped::Moved { bookmark, change: hash, heads, from: old, state })
1d634f69 Andy McCall ↶ 572
        })
573
        .await
574
}
575
a7e6e8e6 Andy McCall ↶ 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
c4e432da Andy McCall ↶ 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() } };
a7e6e8e6 Andy McCall ↶ 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)?);
c4e432da Andy McCall ↶ 625
            let mut added: Vec<(u64, ChangeHash, String, String, Vec<String>)> = Vec::new();
a7e6e8e6 Andy McCall ↶ 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();
c4e432da Andy McCall ↶ 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));
a7e6e8e6 Andy McCall ↶ 640
            }
641
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
c4e432da Andy McCall ↶ 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
                    )
a7e6e8e6 Andy McCall ↶ 660
                })
c4e432da Andy McCall ↶ 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 })
a7e6e8e6 Andy McCall ↶ 664
        })
665
        .await
c4e432da Andy McCall ↶ 666
        .unwrap_or(Changelog { lines: Vec::new(), watched: Vec::new() })
a7e6e8e6 Andy McCall ↶ 667
}
668
9aaa88b2 Andy McCall ↶ 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.
a7e6e8e6 Andy McCall ↶ 685
pub async fn propose(
686
    app: &Shared,
687
    consumer: &RepoRow,
688
    target: &str,
689
    path: &str,
690
    upstream: &RepoRow,
9aaa88b2 Andy McCall ↶ 691
    (from, to): (Vec<ChangeHash>, Vec<ChangeHash>),
f4d6ef41 Andy McCall ↶ 692
    (why, watch, lands): (Why, Vec<String>, bool),
a7e6e8e6 Andy McCall ↶ 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?;
c4e432da Andy McCall ↶ 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
    };
a7e6e8e6 Andy McCall ↶ 709
    let slug = format!("{}/{}", upstream.owner, upstream.name);
710
    let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect();
1b6536f0 Andy McCall ↶ 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})");
9aaa88b2 Andy McCall ↶ 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
    };
a7e6e8e6 Andy McCall ↶ 724
    let body = format!(
f4d6ef41 Andy McCall ↶ 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). {}",
6ae3fa0a Andy McCall ↶ 726
        crate::web::state_label(&short.join("+")),
f4d6ef41 Andy McCall ↶ 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
        }
a7e6e8e6 Andy McCall ↶ 733
    );
734
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
9aaa88b2 Andy McCall ↶ 735
    let (number, id) = match open {
a7e6e8e6 Andy McCall ↶ 736
        Some(p) => {
9aaa88b2 Andy McCall ↶ 737
            if p.title != title || p.body != body {
738
                pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
6ae3fa0a Andy McCall ↶ 739
                pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &crate::web::state_label(&short.join("+"))).await.ok();
9aaa88b2 Andy McCall ↶ 740
            }
741
            (p.number, p.id)
a7e6e8e6 Andy McCall ↶ 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
            if let Ok(label) =
765
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
766
            {
767
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
768
            }
9aaa88b2 Andy McCall ↶ 769
            (p.number, p.id)
a7e6e8e6 Andy McCall ↶ 770
        }
771
    };
9aaa88b2 Andy McCall ↶ 772
    // Amber while the upstream has no checks; cleared by a green bump.
773
    let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten();
774
    match (why == Why::Unverified, unverified) {
775
        (true, l) => {
776
            let l = match l {
777
                Some(l) => Some(l),
778
                None => db::issues::upsert_label(
779
                    &app.db,
780
                    consumer.id,
781
                    "upstream unverified",
782
                    "#e3a008",
783
                    "The upstream has no checks (Diffusion)",
784
                )
785
                .await
786
                .ok(),
787
            };
788
            if let Some(l) = l {
789
                db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok();
790
            }
791
        }
792
        (false, Some(l)) => {
793
            db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok();
794
        }
795
        (false, None) => {}
796
    }
a7e6e8e6 Andy McCall ↶ 797
    Some(number)
798
}
799
f01cb865 Andy McCall ↶ 800
/// Start a bump's pipeline, as a push of it would. Boxed as `Send`, which
801
/// breaks the cycle between a bump starting runs and a passing run
802
/// starting bumps that the compiler cannot see through.
803
fn start_runs(
804
    app: Shared,
805
    row: RepoRow,
806
    bookmark: String,
807
    state: String,
808
    heads: Vec<ChangeHash>,
809
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
810
    Box::pin(async move { crate::flow::on_push(&app, &row, &bookmark, &state, &heads, ACCOUNT).await })
811
}
812
a7e6e8e6 Andy McCall ↶ 813
/// The text of `.oxymodules` on `bookmark` of `name`, if any.
814
async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> {
815
    let b = bookmark.to_string();
816
    app.repos
817
        .read(name, move |r, cache| {
818
            let heads = heads_of(r, &b)?;
819
            if heads.is_empty() {
820
                return Ok(None);
821
            }
822
            let loaded = cache.load(r, &heads)?;
823
            Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) {
824
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
825
                _ => None,
826
            })
827
        })
828
        .await
829
        .ok()
830
        .flatten()
831
}
832
833
/// The upstream `upstream` reached `heads` on its default bookmark: by a
834
/// push (`green` false) or a passing run of that state (`green` true).
835
/// Bump and propose in every consumer that follows it that way (§4.3).
836
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
3776b70d Andy McCall ↶ 837
    propagate_to(app, upstream, heads, green, None).await
838
}
839
840
/// `propagate`, to one consumer only when `only` names it.
841
async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) {
a7e6e8e6 Andy McCall ↶ 842
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
843
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
844
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?")
845
        .bind(hex::encode(id))
846
        .fetch_all(&app.db)
847
        .await
848
        .unwrap_or_default();
9aaa88b2 Andy McCall ↶ 849
    let unverified = unverified(app, upstream).await;
a7e6e8e6 Andy McCall ↶ 850
    for (consumer_id, indexed, path) in rows {
3776b70d Andy McCall ↶ 851
        if only.is_some_and(|o| o != consumer_id) {
852
            continue;
853
        }
a7e6e8e6 Andy McCall ↶ 854
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
855
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
856
        let learned = target(&app.db, &consumer).await;
857
        let modules = oxymodules(app, &cname, &learned).await;
858
        let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path);
859
        let target = r.target.clone().unwrap_or(learned);
860
        if indexed != target {
861
            continue;
862
        }
9aaa88b2 Andy McCall ↶ 863
        // An upstream with no checks never turns green: its moves count
864
        // for green followers too, marked amber (unverified).
a7e6e8e6 Andy McCall ↶ 865
        let go = match r.follow {
866
            Follow::Off => false,
9aaa88b2 Andy McCall ↶ 867
            Follow::Green => green || unverified,
a7e6e8e6 Andy McCall ↶ 868
            Follow::Any => !green,
869
        };
9aaa88b2 Andy McCall ↶ 870
        let why = if green {
871
            Why::Green
872
        } else if unverified {
873
            Why::Unverified
874
        } else {
875
            Why::Moved
876
        };
a7e6e8e6 Andy McCall ↶ 877
        if !go {
878
            continue;
879
        }
880
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
f01cb865 Andy McCall ↶ 881
            Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => {
882
                // Its pipeline runs as for a push, so the proposal is tested.
883
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
f4d6ef41 Andy McCall ↶ 884
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await;
a7e6e8e6 Andy McCall ↶ 885
                tracing::info!(
886
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
887
                    consumer.owner,
888
                    consumer.name,
889
                    upstream.owner,
890
                    upstream.name
891
                );
9aaa88b2 Andy McCall ↶ 892
            }
893
            Ok(Bumped::Already { from, to }) => {
894
                // Already moved: its proposal's text and labels are brought
895
                // up to date all the same.
f4d6ef41 Andy McCall ↶ 896
                propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone(), r.land_auto)).await;
897
                // The upstream may just have turned green under a bump its
898
                // consumer already passed.
899
                if green && r.land_auto {
900
                    tokio::spawn(try_land(app.clone(), consumer.clone(), path.clone()));
901
                }
a7e6e8e6 Andy McCall ↶ 902
            }
7a14bdae Andy McCall ↶ 903
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
a7e6e8e6 Andy McCall ↶ 904
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
3776b70d Andy McCall ↶ 905
        }
906
    }
f4d6ef41 Andy McCall ↶ 907
}
908
909
/// Why a bump was not landed by itself (docs/diffusion.md §4.2).
910
#[derive(Debug, PartialEq, Eq)]
911
pub enum Held {
912
    /// No open bump at that path.
913
    NoBump,
914
    /// The consumer did not choose `land = auto`.
915
    Manual,
916
    /// The upstream has no checks: amber never lands by itself.
917
    Unverified,
918
    /// The upstream state the bump pins has no passing run.
919
    UpstreamUnchecked,
920
    /// The consumer's run on the bump as it is now has not passed.
921
    NotPassed,
922
    /// The landing was refused (required checks, approvals, the trust policy).
923
    Refused(String),
9aaa88b2 Andy McCall ↶ 924
}
925
f4d6ef41 Andy McCall ↶ 926
/// Land the open bump at `path` in `consumer` itself, as the `diffusion`
927
/// account, when the consumer chose `land = auto` (its `.oxymodules` entry,
928
/// else Settings › Diffusion) and both halves of its pill are green: the
929
/// upstream state it pins passed its own run (never amber: an upstream
930
/// with no checks always waits for a person), and the consumer's run on
931
/// the bump as it is now passed. It lands through the web's own path
932
/// (`land_as`), signed by the hub's key, so required checks, approvals and
933
/// the trust policy decide as for anyone; a refusal is noted once on the
934
/// proposal's timeline. The proposal's number when it landed.
935
pub async fn auto_land(app: &Shared, consumer: &RepoRow, path: &str) -> Result<i64, Held> {
936
    let path = path.trim_matches('/');
937
    let bm = bump_bookmark(path);
938
    let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { return Err(Held::NoBump) };
939
    let bump = db::proposals::list(&app.db, consumer.id, &["open"])
940
        .await
941
        .ok()
942
        .and_then(|ps| ps.into_iter().find(|p| p.source_bookmark == bm && p.author == ACCOUNT))
943
        .ok_or(Held::NoBump)?;
944
    let learned = target(&app.db, consumer).await;
945
    let modules = oxymodules(app, &cname, &learned).await;
946
    if !rule(&app.config.diffusion.follow, consumer, modules.as_deref(), path).land_auto {
947
        return Err(Held::Manual);
948
    }
949
    let upstream_id: Option<i64> = sqlx::query_scalar(
950
        "SELECT upstream_repo_id FROM diffusion_pins WHERE consumer_id = ? AND path = ? AND upstream_repo_id IS NOT NULL LIMIT 1",
951
    )
952
    .bind(consumer.id)
953
    .bind(path)
954
    .fetch_optional(&app.db)
955
    .await
956
    .ok()
957
    .flatten();
958
    let Some(upstream) = (match upstream_id {
959
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten(),
960
        None => None,
961
    }) else {
962
        return Err(Held::UpstreamUnchecked);
963
    };
964
    if unverified(app, &upstream).await {
965
        return Err(Held::Unverified);
966
    }
967
    // What the bump pins, and the bump bookmark's heads now.
968
    let (p, b) = (path.to_string(), bm.clone());
969
    let (pinned, now) = app
970
        .repos
971
        .read(&cname, move |r, cache| {
972
            let heads = heads_of(r, &b)?;
973
            if heads.is_empty() {
974
                return Ok((None, heads));
975
            }
976
            let loaded = cache.load(r, &heads)?;
977
            let pin = match loaded.snapshot.files.get(&p).map(|f| &f.content) {
978
                Some(oxy_core::state::FileContent::Opaque { blobs }) => match blobs.first() {
979
                    Some(blob) => oxy_core::subrepo::Pin::decode(&r.read_blob(blob)?).ok(),
980
                    None => None,
981
                },
982
                _ => None,
983
            };
984
            Ok((pin, heads))
985
        })
986
        .await
987
        .map_err(|e| Held::Refused(e.to_string()))?;
988
    let Some(oxy_core::subrepo::Pin::Oxy { heads: pinned, .. }) = pinned else { return Err(Held::UpstreamUnchecked) };
989
    let same = |text: &str, want: &[ChangeHash]| {
990
        let mut a: Vec<String> = text.split('+').filter(|h| !h.is_empty()).map(str::to_string).collect();
991
        let mut b: Vec<String> = want.iter().map(|h| h.to_hex()).collect();
992
        a.sort();
993
        b.sort();
994
        a == b
995
    };
996
    let upstream_runs: Vec<String> = sqlx::query_scalar(
997
        "SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 100",
998
    )
999
    .bind(upstream.id)
1000
    .bind(&upstream.default_bookmark)
1001
    .fetch_all(&app.db)
1002
    .await
1003
    .unwrap_or_default();
1004
    if !upstream_runs.iter().any(|h| same(h, &pinned)) {
1005
        return Err(Held::UpstreamUnchecked);
1006
    }
1007
    let run: Option<(String, String)> =
1008
        sqlx::query_as("SELECT status, heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
1009
            .bind(consumer.id)
1010
            .bind(&bm)
1011
            .fetch_optional(&app.db)
1012
            .await
1013
            .ok()
1014
            .flatten();
1015
    if !run.is_some_and(|(status, heads)| status == "passed" && same(&heads, &now)) {
1016
        return Err(Held::NotPassed);
1017
    }
1018
    let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?;
1019
    let via = "Diffusion, both halves green";
1020
    match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true).await {
1021
        Ok(_) => {
1022
            tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number);
1023
            Ok(bump.number)
1024
        }
1025
        Err(crate::web::proposals::Landing::NotFound) => Err(Held::NoBump),
1026
        Err(crate::web::proposals::Landing::Refused(why)) => {
1027
            // Said once, so the timeline explains why a green bump waits.
1028
            let said = db::proposals::events(&app.db, bump.id)
1029
                .await
1030
                .unwrap_or_default()
1031
                .iter()
1032
                .any(|e| e.event == "not landed by itself" && e.detail == why);
1033
            if !said {
1034
                db::proposals::add_event(&app.db, bump.id, ACCOUNT, "not landed by itself", &why).await.ok();
1035
            }
1036
            Err(Held::Refused(why))
1037
        }
1038
    }
1039
}
1040
1041
/// [`auto_land`] in the background, logged. Boxed as `Send`: a landing
1042
/// moves the target as a push would, which may bump and land again.
1043
pub fn try_land(app: Shared, consumer: RepoRow, path: String) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
1044
    Box::pin(async move {
1045
        match auto_land(&app, &consumer, &path).await {
1046
            Ok(_) | Err(Held::NoBump | Held::Manual) => {}
1047
            Err(why) => tracing::info!("diffusion: {}/{} {path} waits for a person: {why:?}", consumer.owner, consumer.name),
1048
        }
1049
    })
1050
}
1051
1052
/// Try every open bump of `consumer` (its setting turned to `auto`).
1053
pub async fn land_ready(app: &Shared, consumer: &RepoRow) {
1054
    let paths: Vec<String> = sqlx::query_scalar("SELECT DISTINCT path FROM diffusion_pins WHERE consumer_id = ?")
1055
        .bind(consumer.id)
1056
        .fetch_all(&app.db)
1057
        .await
1058
        .unwrap_or_default();
1059
    for path in paths {
1060
        tokio::spawn(try_land(app.clone(), consumer.clone(), path));
1061
    }
1062
}
1063
9aaa88b2 Andy McCall ↶ 1064
/// Whether `upstream` has no checks of its own: Flow is off for it, or
1065
/// its default bookmark has never had a run. Its moves are then
1066
/// "unverified" (amber), never green.
1067
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
1068
    if !crate::flow::enabled(app, upstream) {
1069
        return true;
1070
    }
1071
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND bookmark = ?")
1072
        .bind(upstream.id)
1073
        .bind(&upstream.default_bookmark)
1074
        .fetch_one(&app.db)
1075
        .await
1076
        .unwrap_or(0);
1077
    runs == 0
3776b70d Andy McCall ↶ 1078
}
1079
1080
/// The repository on this hub with Oxygen id `id` (hex).
1081
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
1082
    for row in db::repos::list(&app.db).await.ok()? {
1083
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
1084
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
1085
            return Some(row);
1086
        }
1087
    }
1088
    None
1089
}
1090
1091
/// The newest state of `upstream`'s default bookmark whose run passed.
1092
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
1093
    let heads: String =
1094
        sqlx::query_scalar("SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 1")
1095
            .bind(upstream.id)
1096
            .bind(&upstream.default_bookmark)
1097
            .fetch_optional(&app.db)
1098
            .await
1099
            .ok()
1100
            .flatten()?;
1101
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
1102
    (!h.is_empty()).then_some(h)
1103
}
1104
1105
/// Bring `consumer` up to date with the upstreams it pins, at once: when
1106
/// it turns Diffusion on, it is bumped to each upstream's newest state
1107
/// that passed (or simply its newest, when it follows every move).
1108
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
1109
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = ?")
1110
        .bind(consumer.id)
1111
        .fetch_all(&app.db)
1112
        .await
1113
        .unwrap_or_default();
1114
    for id in upstreams {
1115
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
1116
        if let Some(h) = last_green(app, &upstream).await {
1117
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
1118
        }
1119
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
1120
        let b = upstream.default_bookmark.clone();
1121
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
1122
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
a7e6e8e6 Andy McCall ↶ 1123
        }
1124
    }
1125
}
1126
e7158350 Andy McCall ↶ 1127
/// When a consumer takes an upstream's moves (§4.2).
1128
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
1129
#[serde(rename_all = "lowercase")]
1130
pub enum Follow {
1131
    Off,
1132
    /// When the upstream state's checks pass.
1133
    Green,
1134
    /// On every move.
1135
    Any,
1136
}
1137
1138
impl Follow {
1139
    pub fn parse(s: &str) -> Option<Follow> {
1140
        match s.trim() {
1141
            "off" => Some(Follow::Off),
1142
            "green" => Some(Follow::Green),
1143
            "any" => Some(Follow::Any),
1144
            _ => None,
eb2a3502 Andy McCall ↶ 1145
        }
1146
    }
1147
1148
    pub fn as_str(self) -> &'static str {
1149
        match self {
1150
            Follow::Off => "off",
1151
            Follow::Green => "green",
1152
            Follow::Any => "any",
e7158350 Andy McCall ↶ 1153
        }
1154
    }
1155
}
1156
1157
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
1158
/// repository setting and the site's default, in that order.
1159
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1160
pub struct Rule {
1161
    pub follow: Follow,
f4d6ef41 Andy McCall ↶ 1162
    /// Land a bump itself once both halves are green (`land = auto`, or
1163
    /// the repository's setting); otherwise it only proposes.
e7158350 Andy McCall ↶ 1164
    pub land_auto: bool,
1165
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
1166
    pub target: Option<String>,
1167
    /// Paths the consumer watches: upstream changes touching them are
1168
    /// flagged in a bump's changelog.
1169
    pub watch: Vec<String>,
1170
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
1171
    pub from: &'static str,
1172
}
1173
1174
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
1175
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
1176
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
1177
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
1178
    let mut here = false;
1179
    for line in text.lines() {
1180
        let line = line.trim();
1181
        if line.is_empty() || line.starts_with('#') {
1182
            continue;
1183
        }
1184
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1185
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
1186
            continue;
1187
        }
1188
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
1189
        let v = v.trim();
1190
        let unquoted = v.trim_matches('"').trim();
1191
        match k.trim() {
1192
            "follow" => follow = Follow::parse(unquoted),
1193
            "land" => land = Some(unquoted == "auto"),
1194
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
1195
            "watch" => {
1196
                watch = v
1197
                    .trim_start_matches('[')
1198
                    .trim_end_matches(']')
1199
                    .split(',')
1200
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
1201
                    .filter(|w| !w.is_empty())
1202
                    .collect()
1203
            }
1204
            _ => {}
1205
        }
1206
    }
1207
    (follow, land, target, watch)
1208
}
1209
1210
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
1211
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
1212
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
1213
    let (follow, from) = match follow {
1214
        Some(f) => (f, ".oxymodules"),
1215
        None => match Follow::parse(&row.diffusion) {
1216
            Some(f) => (f, "setting"),
1217
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
1218
        },
1219
    };
f4d6ef41 Andy McCall ↶ 1220
    // Landing by itself: the entry's word, else the setting; never by default.
1221
    let land_auto = land.unwrap_or(row.diffusion_land == "auto");
1222
    Rule { follow, land_auto, target, watch, from }
e7158350 Andy McCall ↶ 1223
}
1224
1225
#[cfg(test)]
1226
mod tests {
1227
    use super::*;
1228
1229
    const MODULES: &str = r#"
1230
[rstar-api]
1231
url = https://oxygenhub.io/doomsdayone/rstar-api
1232
follow = green
1233
land = "propose"
1234
target = develop
1235
watch = ["src/x16/irq*", "src/x16/frame*"]
1236
1237
[other]
1238
url = https://example/other
1239
follow = any
1240
"#;
1241
1242
    fn row(diffusion: &str) -> RepoRow {
1243
        RepoRow {
1244
            id: 1,
1245
            owner: "o".into(),
1246
            name: "n".into(),
1247
            description: String::new(),
1248
            default_bookmark: "main".into(),
1249
            visibility: "public".into(),
1250
            created_ms: 0,
1251
            updated_ms: 0,
1252
            git_mirror: false,
1253
            git_mirror_state: String::new(),
1254
            forked_from: None,
1255
            topics: String::new(),
1256
            flow: String::new(),
1257
            diffusion: diffusion.into(),
1258
            ..Default::default()
1259
        }
f4d6ef41 Andy McCall ↶ 1260
    }
1261
1262
    #[test]
1263
    fn landing_by_itself_is_chosen_per_repository_and_per_entry() {
1264
        let mut r = row("green");
1265
        assert!(!rule("off", &r, None, "rstar-api").land_auto, "never by default");
1266
        r.diffusion_land = "auto".into();
1267
        assert!(rule("off", &r, None, "rstar-api").land_auto, "the setting");
1268
        assert!(!rule("off", &r, Some(MODULES), "rstar-api").land_auto, "the entry's `land = propose` wins");
1269
        assert!(rule("off", &r, Some(MODULES), "other").land_auto, "an entry without `land` takes the setting");
1270
        r.diffusion_land = String::new();
1271
        let auto = MODULES.replace(r#"land = "propose""#, "land = auto");
1272
        assert!(rule("off", &r, Some(&auto), "rstar-api").land_auto);
c4e432da Andy McCall ↶ 1273
    }
1274
1275
    #[test]
1276
    fn watch_patterns_match_paths() {
1277
        assert!(watched("src/x16/irq*", "src/x16/irq.s"));
1278
        assert!(watched("src/x16/irq*", "src/x16/irq_wait.c"));
1279
        assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment");
1280
        assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c"));
1281
        assert!(watched("src/x16", "src/x16/bank.s"), "a directory");
1282
        assert!(!watched("src/x16", "src/x160/bank.s"));
1283
        assert!(watched("include/r?m.h", "include/rim.h"));
1284
        assert!(!watched("", "anything"));
e7158350 Andy McCall ↶ 1285
    }
1286
1287
    #[test]
1288
    fn oxymodules_keys_are_read_per_entry() {
1289
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
1290
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
1291
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
1292
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
1293
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
1294
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
1295
    }
1296
1297
    #[test]
1298
    fn oxymodules_wins_then_the_setting_then_the_site() {
1299
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
1300
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
1301
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
1302
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
1303
        let r = rule("green", &row(""), None, "rstar-api");
1304
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
1305
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
1306
    }
1307
}