OXYGENHUB
# a2ef53c5db26 oxygenhub / crates / hub / src / diffusion.rs
56.7 KiB · Rust · last changed Live pages L1: an event bus of topics, versions and a replay buffer 20 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();
bff9106e Andy McCall ↶ 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;
a7e6e8e6 Andy McCall ↶ 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
            }
9aaa88b2 Andy McCall ↶ 784
            (p.number, p.id)
a7e6e8e6 Andy McCall ↶ 785
        }
786
    };
9aaa88b2 Andy McCall ↶ 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
    }
a7e6e8e6 Andy McCall ↶ 812
    Some(number)
813
}
814
f01cb865 Andy McCall ↶ 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
a7e6e8e6 Andy McCall ↶ 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) {
3776b70d Andy McCall ↶ 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>) {
a7e6e8e6 Andy McCall ↶ 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();
9aaa88b2 Andy McCall ↶ 864
    let unverified = unverified(app, upstream).await;
a7e6e8e6 Andy McCall ↶ 865
    for (consumer_id, indexed, path) in rows {
3776b70d Andy McCall ↶ 866
        if only.is_some_and(|o| o != consumer_id) {
867
            continue;
868
        }
a7e6e8e6 Andy McCall ↶ 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
        }
9aaa88b2 Andy McCall ↶ 878
        // An upstream with no checks never turns green: its moves count
879
        // for green followers too, marked amber (unverified).
a7e6e8e6 Andy McCall ↶ 880
        let go = match r.follow {
881
            Follow::Off => false,
9aaa88b2 Andy McCall ↶ 882
            Follow::Green => green || unverified,
a7e6e8e6 Andy McCall ↶ 883
            Follow::Any => !green,
884
        };
9aaa88b2 Andy McCall ↶ 885
        let why = if green {
886
            Why::Green
887
        } else if unverified {
888
            Why::Unverified
889
        } else {
890
            Why::Moved
891
        };
a7e6e8e6 Andy McCall ↶ 892
        if !go {
893
            continue;
894
        }
895
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
f01cb865 Andy McCall ↶ 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));
f4d6ef41 Andy McCall ↶ 899
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone(), r.land_auto)).await;
7e01a766 Andy McCall ↶ 900
                app.events.live.publish(&crate::events::topic::diffusion(consumer.id), "bump");
901
                app.events.live.publish(&crate::events::topic::diffusion(upstream.id), "bump");
a7e6e8e6 Andy McCall ↶ 902
                tracing::info!(
903
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
904
                    consumer.owner,
905
                    consumer.name,
906
                    upstream.owner,
907
                    upstream.name
908
                );
9aaa88b2 Andy McCall ↶ 909
            }
910
            Ok(Bumped::Already { from, to }) => {
911
                // Already moved: its proposal's text and labels are brought
912
                // up to date all the same.
f4d6ef41 Andy McCall ↶ 913
                propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone(), r.land_auto)).await;
914
                // The upstream may just have turned green under a bump its
915
                // consumer already passed.
916
                if green && r.land_auto {
917
                    tokio::spawn(try_land(app.clone(), consumer.clone(), path.clone()));
918
                }
a7e6e8e6 Andy McCall ↶ 919
            }
7a14bdae Andy McCall ↶ 920
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
a7e6e8e6 Andy McCall ↶ 921
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
3776b70d Andy McCall ↶ 922
        }
923
    }
f4d6ef41 Andy McCall ↶ 924
}
925
926
/// Why a bump was not landed by itself (docs/diffusion.md §4.2).
927
#[derive(Debug, PartialEq, Eq)]
928
pub enum Held {
929
    /// No open bump at that path.
930
    NoBump,
931
    /// The consumer did not choose `land = auto`.
932
    Manual,
933
    /// The upstream has no checks: amber never lands by itself.
934
    Unverified,
935
    /// The upstream state the bump pins has no passing run.
936
    UpstreamUnchecked,
937
    /// The consumer's run on the bump as it is now has not passed.
938
    NotPassed,
939
    /// The landing was refused (required checks, approvals, the trust policy).
940
    Refused(String),
9aaa88b2 Andy McCall ↶ 941
}
942
f4d6ef41 Andy McCall ↶ 943
/// Land the open bump at `path` in `consumer` itself, as the `diffusion`
944
/// account, when the consumer chose `land = auto` (its `.oxymodules` entry,
945
/// else Settings › Diffusion) and both halves of its pill are green: the
946
/// upstream state it pins passed its own run (never amber: an upstream
947
/// with no checks always waits for a person), and the consumer's run on
948
/// the bump as it is now passed. It lands through the web's own path
949
/// (`land_as`), signed by the hub's key, so required checks, approvals and
950
/// the trust policy decide as for anyone; a refusal is noted once on the
951
/// proposal's timeline. The proposal's number when it landed.
952
pub async fn auto_land(app: &Shared, consumer: &RepoRow, path: &str) -> Result<i64, Held> {
953
    let path = path.trim_matches('/');
954
    let bm = bump_bookmark(path);
955
    let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { return Err(Held::NoBump) };
956
    let bump = db::proposals::list(&app.db, consumer.id, &["open"])
957
        .await
958
        .ok()
959
        .and_then(|ps| ps.into_iter().find(|p| p.source_bookmark == bm && p.author == ACCOUNT))
960
        .ok_or(Held::NoBump)?;
961
    let learned = target(&app.db, consumer).await;
962
    let modules = oxymodules(app, &cname, &learned).await;
963
    if !rule(&app.config.diffusion.follow, consumer, modules.as_deref(), path).land_auto {
964
        return Err(Held::Manual);
965
    }
966
    let upstream_id: Option<i64> = sqlx::query_scalar(
967
        "SELECT upstream_repo_id FROM diffusion_pins WHERE consumer_id = ? AND path = ? AND upstream_repo_id IS NOT NULL LIMIT 1",
968
    )
969
    .bind(consumer.id)
970
    .bind(path)
971
    .fetch_optional(&app.db)
972
    .await
973
    .ok()
974
    .flatten();
975
    let Some(upstream) = (match upstream_id {
976
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten(),
977
        None => None,
978
    }) else {
979
        return Err(Held::UpstreamUnchecked);
980
    };
981
    if unverified(app, &upstream).await {
982
        return Err(Held::Unverified);
983
    }
984
    // What the bump pins, and the bump bookmark's heads now.
985
    let (p, b) = (path.to_string(), bm.clone());
986
    let (pinned, now) = app
987
        .repos
988
        .read(&cname, move |r, cache| {
989
            let heads = heads_of(r, &b)?;
990
            if heads.is_empty() {
991
                return Ok((None, heads));
992
            }
993
            let loaded = cache.load(r, &heads)?;
994
            let pin = match loaded.snapshot.files.get(&p).map(|f| &f.content) {
995
                Some(oxy_core::state::FileContent::Opaque { blobs }) => match blobs.first() {
996
                    Some(blob) => oxy_core::subrepo::Pin::decode(&r.read_blob(blob)?).ok(),
997
                    None => None,
998
                },
999
                _ => None,
1000
            };
1001
            Ok((pin, heads))
1002
        })
1003
        .await
1004
        .map_err(|e| Held::Refused(e.to_string()))?;
1005
    let Some(oxy_core::subrepo::Pin::Oxy { heads: pinned, .. }) = pinned else { return Err(Held::UpstreamUnchecked) };
1006
    let same = |text: &str, want: &[ChangeHash]| {
1007
        let mut a: Vec<String> = text.split('+').filter(|h| !h.is_empty()).map(str::to_string).collect();
1008
        let mut b: Vec<String> = want.iter().map(|h| h.to_hex()).collect();
1009
        a.sort();
1010
        b.sort();
1011
        a == b
1012
    };
1013
    let upstream_runs: Vec<String> = sqlx::query_scalar(
1014
        "SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 100",
1015
    )
1016
    .bind(upstream.id)
1017
    .bind(&upstream.default_bookmark)
1018
    .fetch_all(&app.db)
1019
    .await
1020
    .unwrap_or_default();
1021
    if !upstream_runs.iter().any(|h| same(h, &pinned)) {
1022
        return Err(Held::UpstreamUnchecked);
1023
    }
1024
    let run: Option<(String, String)> =
1025
        sqlx::query_as("SELECT status, heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
1026
            .bind(consumer.id)
1027
            .bind(&bm)
1028
            .fetch_optional(&app.db)
1029
            .await
1030
            .ok()
1031
            .flatten();
1032
    if !run.is_some_and(|(status, heads)| status == "passed" && same(&heads, &now)) {
1033
        return Err(Held::NotPassed);
1034
    }
1035
    let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?;
1036
    let via = "Diffusion, both halves green";
1037
    match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true).await {
1038
        Ok(_) => {
1039
            tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number);
1040
            Ok(bump.number)
1041
        }
1042
        Err(crate::web::proposals::Landing::NotFound) => Err(Held::NoBump),
1043
        Err(crate::web::proposals::Landing::Refused(why)) => {
1044
            // Said once, so the timeline explains why a green bump waits.
1045
            let said = db::proposals::events(&app.db, bump.id)
1046
                .await
1047
                .unwrap_or_default()
1048
                .iter()
1049
                .any(|e| e.event == "not landed by itself" && e.detail == why);
1050
            if !said {
1051
                db::proposals::add_event(&app.db, bump.id, ACCOUNT, "not landed by itself", &why).await.ok();
1052
            }
1053
            Err(Held::Refused(why))
1054
        }
1055
    }
1056
}
1057
1058
/// [`auto_land`] in the background, logged. Boxed as `Send`: a landing
1059
/// moves the target as a push would, which may bump and land again.
1060
pub fn try_land(app: Shared, consumer: RepoRow, path: String) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
1061
    Box::pin(async move {
1062
        match auto_land(&app, &consumer, &path).await {
1063
            Ok(_) | Err(Held::NoBump | Held::Manual) => {}
1064
            Err(why) => tracing::info!("diffusion: {}/{} {path} waits for a person: {why:?}", consumer.owner, consumer.name),
1065
        }
1066
    })
1067
}
1068
1069
/// Try every open bump of `consumer` (its setting turned to `auto`).
1070
pub async fn land_ready(app: &Shared, consumer: &RepoRow) {
1071
    let paths: Vec<String> = sqlx::query_scalar("SELECT DISTINCT path FROM diffusion_pins WHERE consumer_id = ?")
1072
        .bind(consumer.id)
1073
        .fetch_all(&app.db)
1074
        .await
1075
        .unwrap_or_default();
1076
    for path in paths {
1077
        tokio::spawn(try_land(app.clone(), consumer.clone(), path));
1078
    }
1079
}
1080
9aaa88b2 Andy McCall ↶ 1081
/// Whether `upstream` has no checks of its own: Flow is off for it, or
1082
/// its default bookmark has never had a run. Its moves are then
1083
/// "unverified" (amber), never green.
1084
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
1085
    if !crate::flow::enabled(app, upstream) {
1086
        return true;
1087
    }
1088
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND bookmark = ?")
1089
        .bind(upstream.id)
1090
        .bind(&upstream.default_bookmark)
1091
        .fetch_one(&app.db)
1092
        .await
1093
        .unwrap_or(0);
1094
    runs == 0
3776b70d Andy McCall ↶ 1095
}
1096
1097
/// The repository on this hub with Oxygen id `id` (hex).
1098
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
1099
    for row in db::repos::list(&app.db).await.ok()? {
1100
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
1101
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
1102
            return Some(row);
1103
        }
1104
    }
1105
    None
1106
}
1107
1108
/// The newest state of `upstream`'s default bookmark whose run passed.
1109
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
1110
    let heads: String =
1111
        sqlx::query_scalar("SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 1")
1112
            .bind(upstream.id)
1113
            .bind(&upstream.default_bookmark)
1114
            .fetch_optional(&app.db)
1115
            .await
1116
            .ok()
1117
            .flatten()?;
1118
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
1119
    (!h.is_empty()).then_some(h)
1120
}
1121
1122
/// Bring `consumer` up to date with the upstreams it pins, at once: when
1123
/// it turns Diffusion on, it is bumped to each upstream's newest state
1124
/// that passed (or simply its newest, when it follows every move).
1125
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
1126
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = ?")
1127
        .bind(consumer.id)
1128
        .fetch_all(&app.db)
1129
        .await
1130
        .unwrap_or_default();
1131
    for id in upstreams {
1132
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
1133
        if let Some(h) = last_green(app, &upstream).await {
1134
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
1135
        }
1136
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
1137
        let b = upstream.default_bookmark.clone();
1138
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
1139
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
a7e6e8e6 Andy McCall ↶ 1140
        }
1141
    }
1142
}
1143
e7158350 Andy McCall ↶ 1144
/// When a consumer takes an upstream's moves (§4.2).
1145
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
1146
#[serde(rename_all = "lowercase")]
1147
pub enum Follow {
1148
    Off,
1149
    /// When the upstream state's checks pass.
1150
    Green,
1151
    /// On every move.
1152
    Any,
1153
}
1154
1155
impl Follow {
1156
    pub fn parse(s: &str) -> Option<Follow> {
1157
        match s.trim() {
1158
            "off" => Some(Follow::Off),
1159
            "green" => Some(Follow::Green),
1160
            "any" => Some(Follow::Any),
1161
            _ => None,
eb2a3502 Andy McCall ↶ 1162
        }
1163
    }
1164
1165
    pub fn as_str(self) -> &'static str {
1166
        match self {
1167
            Follow::Off => "off",
1168
            Follow::Green => "green",
1169
            Follow::Any => "any",
e7158350 Andy McCall ↶ 1170
        }
1171
    }
1172
}
1173
1174
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
1175
/// repository setting and the site's default, in that order.
1176
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1177
pub struct Rule {
1178
    pub follow: Follow,
f4d6ef41 Andy McCall ↶ 1179
    /// Land a bump itself once both halves are green (`land = auto`, or
1180
    /// the repository's setting); otherwise it only proposes.
e7158350 Andy McCall ↶ 1181
    pub land_auto: bool,
1182
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
1183
    pub target: Option<String>,
1184
    /// Paths the consumer watches: upstream changes touching them are
1185
    /// flagged in a bump's changelog.
1186
    pub watch: Vec<String>,
1187
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
1188
    pub from: &'static str,
1189
}
1190
1191
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
1192
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
1193
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
1194
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
1195
    let mut here = false;
5360cea3 Andy McCall ↶ 1196
    let mut lines = text.lines();
1197
    while let Some(line) = lines.next() {
e7158350 Andy McCall ↶ 1198
        let line = line.trim();
1199
        if line.is_empty() || line.starts_with('#') {
1200
            continue;
1201
        }
1202
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1203
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
1204
            continue;
1205
        }
1206
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
5360cea3 Andy McCall ↶ 1207
        // A list may run over several lines (oxygenhub#200), as TOML allows:
1208
        // read on to its closing `]`.
1209
        let mut v = v.trim().to_string();
1210
        if v.starts_with('[') && !v.contains(']') {
1211
            for more in lines.by_ref() {
1212
                let more = more.trim();
1213
                if more.starts_with('#') {
1214
                    continue;
1215
                }
1216
                v.push_str(more);
1217
                if more.contains(']') {
1218
                    break;
1219
                }
1220
            }
1221
        }
1222
        let v = v.as_str();
e7158350 Andy McCall ↶ 1223
        let unquoted = v.trim_matches('"').trim();
1224
        match k.trim() {
1225
            "follow" => follow = Follow::parse(unquoted),
1226
            "land" => land = Some(unquoted == "auto"),
1227
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
1228
            "watch" => {
1229
                watch = v
1230
                    .trim_start_matches('[')
1231
                    .trim_end_matches(']')
1232
                    .split(',')
1233
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
1234
                    .filter(|w| !w.is_empty())
1235
                    .collect()
1236
            }
1237
            _ => {}
1238
        }
1239
    }
1240
    (follow, land, target, watch)
1241
}
1242
1243
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
1244
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
1245
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
1246
    let (follow, from) = match follow {
1247
        Some(f) => (f, ".oxymodules"),
1248
        None => match Follow::parse(&row.diffusion) {
1249
            Some(f) => (f, "setting"),
1250
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
1251
        },
1252
    };
f4d6ef41 Andy McCall ↶ 1253
    // Landing by itself: the entry's word, else the setting; never by default.
1254
    let land_auto = land.unwrap_or(row.diffusion_land == "auto");
1255
    Rule { follow, land_auto, target, watch, from }
e7158350 Andy McCall ↶ 1256
}
1257
1258
#[cfg(test)]
1259
mod tests {
1260
    use super::*;
1261
1262
    const MODULES: &str = r#"
1263
[rstar-api]
1264
url = https://oxygenhub.io/doomsdayone/rstar-api
1265
follow = green
1266
land = "propose"
1267
target = develop
1268
watch = ["src/x16/irq*", "src/x16/frame*"]
1269
1270
[other]
1271
url = https://example/other
1272
follow = any
1273
"#;
1274
1275
    fn row(diffusion: &str) -> RepoRow {
1276
        RepoRow {
1277
            id: 1,
1278
            owner: "o".into(),
1279
            name: "n".into(),
1280
            description: String::new(),
1281
            default_bookmark: "main".into(),
1282
            visibility: "public".into(),
1283
            created_ms: 0,
1284
            updated_ms: 0,
1285
            git_mirror: false,
1286
            git_mirror_state: String::new(),
1287
            forked_from: None,
1288
            topics: String::new(),
1289
            flow: String::new(),
1290
            diffusion: diffusion.into(),
1291
            ..Default::default()
1292
        }
f4d6ef41 Andy McCall ↶ 1293
    }
1294
1295
    #[test]
1296
    fn landing_by_itself_is_chosen_per_repository_and_per_entry() {
1297
        let mut r = row("green");
1298
        assert!(!rule("off", &r, None, "rstar-api").land_auto, "never by default");
1299
        r.diffusion_land = "auto".into();
1300
        assert!(rule("off", &r, None, "rstar-api").land_auto, "the setting");
1301
        assert!(!rule("off", &r, Some(MODULES), "rstar-api").land_auto, "the entry's `land = propose` wins");
1302
        assert!(rule("off", &r, Some(MODULES), "other").land_auto, "an entry without `land` takes the setting");
1303
        r.diffusion_land = String::new();
1304
        let auto = MODULES.replace(r#"land = "propose""#, "land = auto");
1305
        assert!(rule("off", &r, Some(&auto), "rstar-api").land_auto);
c4e432da Andy McCall ↶ 1306
    }
1307
1308
    #[test]
1309
    fn watch_patterns_match_paths() {
1310
        assert!(watched("src/x16/irq*", "src/x16/irq.s"));
1311
        assert!(watched("src/x16/irq*", "src/x16/irq_wait.c"));
1312
        assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment");
1313
        assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c"));
1314
        assert!(watched("src/x16", "src/x16/bank.s"), "a directory");
1315
        assert!(!watched("src/x16", "src/x160/bank.s"));
1316
        assert!(watched("include/r?m.h", "include/rim.h"));
1317
        assert!(!watched("", "anything"));
e7158350 Andy McCall ↶ 1318
    }
1319
1320
    #[test]
1321
    fn oxymodules_keys_are_read_per_entry() {
1322
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
1323
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
1324
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
1325
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
1326
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
1327
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
5360cea3 Andy McCall ↶ 1328
        // #200: a list over several lines, as in trailblazers, comments
1329
        // and the keys after it included.
1330
        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";
1331
        let (f, _, _, watch) = module_keys(multi, "rstar-api");
1332
        assert_eq!(watch, vec!["src/x16/rbm*", "src/x16/rdm.c", "src/mega65/rsm.c"]);
1333
        assert_eq!(f, Some(Follow::Green), "the key after the list is still read");
e7158350 Andy McCall ↶ 1334
    }
1335
1336
    #[test]
1337
    fn oxymodules_wins_then_the_setting_then_the_site() {
1338
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
1339
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
1340
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
1341
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
1342
        let r = rule("green", &row(""), None, "rstar-api");
1343
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
1344
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
1345
    }
1346
}