OXYGENHUB
◆ close-proposal-140 oxygenhub / crates / hub / src / diffusion.rs
36.3 KiB · Rust · last changed Diffusion: flag upstream changes that touch what a game watches 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();
54
    let Ok(mut tx) = app.db.begin().await else { return };
55
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
56
        .bind(row.id)
57
        .bind(bookmark)
58
        .execute(&mut *tx)
59
        .await
60
        .is_ok();
61
    for (path, id, pinned) in &pins {
62
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
63
        ok &=
64
            sqlx::query("INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms) VALUES (?, ?, ?, ?, ?, ?)")
65
                .bind(row.id)
66
                .bind(bookmark)
67
                .bind(path)
68
                .bind(hex::encode(id))
69
                .bind(heads.join("+"))
70
                .bind(now)
71
                .execute(&mut *tx)
72
                .await
73
                .is_ok();
74
    }
75
    if ok {
76
        tx.commit().await.ok();
77
    }
78
}
79
80
/// After a push: index the pins of each moved bookmark that is followed.
81
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
82
    let followed = followed_bookmarks(&app.db, row).await;
83
    for (bookmark, _, heads) in moved {
84
        if followed.contains(bookmark) {
85
            record(app, row, bookmark, heads).await;
86
        }
87
    }
88
}
89
90
/// Index every repository's followed bookmarks, for a hub that had no
91
/// index before (run once at start, in the background).
92
pub async fn backfill(app: &Shared) {
93
    let Ok(rows) = db::repos::list(&app.db).await else { return };
94
    for row in rows {
95
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
96
        for bookmark in followed_bookmarks(&app.db, &row).await {
97
            let b = bookmark.clone();
98
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
99
            if let Ok(heads) = heads {
100
                record(app, &row, &bookmark, &heads).await;
101
            }
102
        }
103
    }
104
}
105
106
/// A bookmark's heads as the repository has them now (not a cached
107
/// list, which a push refreshes a moment later); empty when it has none.
108
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
109
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
110
}
111
112
/// One consumer of an upstream, as an upstream's page and API show it.
113
#[derive(Clone, Debug, Serialize)]
114
pub struct Consumer {
115
    pub owner: String,
116
    pub name: String,
117
    pub bookmark: String,
118
    pub path: String,
119
    /// The upstream heads it pins, hex.
120
    pub pinned: Vec<String>,
121
    /// Upstream changes on its default bookmark the pin lacks; `None` when
122
    /// the hub cannot tell (the pinned changes are not in the upstream).
123
    pub behind: Option<usize>,
124
}
125
126
/// The consumers of `row` that `account` may read, with how far behind
127
/// the upstream's default bookmark each pin is.
128
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
129
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
130
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
131
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
132
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = ? ORDER BY consumer_id, bookmark, path",
133
    )
134
    .bind(hex::encode(id))
135
    .fetch_all(&app.db)
136
    .await
137
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
138
    let mut out = Vec::new();
139
    let mut pinned_sets = Vec::new();
140
    for (consumer_id, bookmark, path, heads) in found {
141
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
142
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
143
            continue;
144
        }
145
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
146
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
147
        out.push(Consumer { owner: c.owner, name: c.name, bookmark, path, pinned, behind: None });
148
    }
149
    if out.is_empty() {
150
        return Ok(out);
151
    }
152
    let default = row.default_bookmark.clone();
153
    let behind = app
154
        .repos
155
        .read(&name, move |r, cache| {
156
            let tip = heads_of(r, &default)?;
157
            let tip = cache.summary(r, &tip)?;
158
            Ok(pinned_sets
159
                .iter()
160
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
161
                .collect::<Vec<_>>())
162
        })
163
        .await?;
164
    for (c, b) in out.iter_mut().zip(behind) {
165
        c.behind = b;
166
    }
167
    Ok(out)
168
}
e7158350 Andy McCall ↶ 169
1d634f69 Andy McCall ↶ 170
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
171
pub fn bump_bookmark(path: &str) -> String {
172
    format!("diffusion/{}", path.trim_matches('/'))
173
}
174
175
/// What a bump did.
176
#[derive(Clone, Debug, PartialEq, Eq)]
177
pub enum Bumped {
178
    /// The bump bookmark now holds the target plus one change moving the pin.
f01cb865 Andy McCall ↶ 179
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash>, state: String },
1d634f69 Andy McCall ↶ 180
    /// The pin already holds the upstream state.
181
    UpToDate,
182
    /// The bump bookmark already moves the pin to that state.
9aaa88b2 Andy McCall ↶ 183
    Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> },
7a14bdae Andy McCall ↶ 184
    /// The pin names changes the upstream does not hold: not followed.
185
    Unknown,
186
    /// Taking the upstream's state with the pin's own extra changes would
187
    /// conflict.
188
    Conflicts,
1d634f69 Andy McCall ↶ 189
    /// The consumer has no Oxygen pin of that upstream at `path` on its
190
    /// target bookmark.
191
    NoPin,
192
}
193
7a14bdae Andy McCall ↶ 194
/// Where a consumer's pin `old` moves when the upstream reaches `new`,
195
/// worked out in the upstream (§4.3).
196
#[derive(Clone, Debug, PartialEq, Eq)]
197
pub enum Plan {
198
    /// To these heads, which add this many changes.
199
    To(Vec<ChangeHash>, usize),
200
    /// Nothing to add.
201
    UpToDate,
202
    /// The pin names changes the upstream does not hold (rewritten, or
203
    /// from elsewhere): not followed.
204
    Unknown,
205
    /// Taking `new` with the pin's own extra changes would conflict.
206
    Conflicts,
207
}
208
209
/// The pin `old` moved to the upstream's `new`. When the pin has changes
210
/// `new` lacks (say, from an upstream proposal not landed yet), they are
211
/// kept: changes that do not touch each other combine, so the move is to
212
/// both, never dropping what the consumer already relies on.
213
pub async fn plan(app: &Shared, upstream: &RepoName, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Result<Plan, crate::repos::RepoError> {
1d634f69 Andy McCall ↶ 214
    app.repos
215
        .read(upstream, move |r, cache| {
7a14bdae Andy McCall ↶ 216
            let Ok(o) = cache.summary(r, &old) else { return Ok(Plan::Unknown) };
217
            let n = cache.summary(r, &new)?;
218
            if o.changes.iter().all(|c| n.changes.contains(c)) {
219
                let added = n.changes.len() - o.changes.len();
220
                return Ok(if added == 0 { Plan::UpToDate } else { Plan::To(new, added) });
221
            }
222
            let mut both = new.clone();
223
            both.extend(old.iter().copied());
224
            let heads = r.load_state(&both)?.heads();
225
            let b = cache.summary(r, &heads)?;
226
            if b.conflicted && !o.conflicted {
227
                return Ok(Plan::Conflicts);
1d634f69 Andy McCall ↶ 228
            }
7a14bdae Andy McCall ↶ 229
            let added = b.changes.len() - o.changes.len();
230
            Ok(if added == 0 { Plan::UpToDate } else { Plan::To(heads, added) })
1d634f69 Andy McCall ↶ 231
        })
232
        .await
233
}
234
235
/// Move `consumer`'s pin of `upstream` at `path` to `new_heads`, on a bump
236
/// bookmark built from `target` (§4.3): one change, signed with the hub's
237
/// key, on the target's heads. The upstream decides forwardness.
238
pub async fn bump(
239
    app: &Shared,
240
    consumer: &RepoRow,
241
    target: &str,
242
    path: &str,
243
    upstream: &RepoRow,
244
    new_heads: Vec<ChangeHash>,
245
) -> Result<Bumped, crate::repos::RepoError> {
246
    use oxy_core::identity::Domain;
247
    use oxy_core::subrepo::Pin;
248
    let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name))
249
    else {
250
        return Ok(Bumped::NoPin);
251
    };
252
    let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?;
253
    // The consumer's current pin at `path` on its target, and on its bump
254
    // bookmark if it has one.
255
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
256
    let (current, bumped) = app
257
        .repos
258
        .read(&cname, move |r, cache| {
259
            let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> {
260
                let heads = heads_of(r, bookmark)?;
261
                if heads.is_empty() {
262
                    return Ok(None);
263
                }
264
                let loaded = cache.load(r, &heads)?;
265
                let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) };
266
                let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) };
267
                let Some(b) = blobs.first() else { return Ok(None) };
268
                Ok(Pin::decode(&r.read_blob(b)?).ok())
269
            };
270
            Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?))
271
        })
272
        .await?;
273
    let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) };
7a14bdae Andy McCall ↶ 274
    if repo != upstream_id {
275
        return Ok(Bumped::NoPin);
276
    }
277
    let new_heads = match plan(app, &uname, old.clone(), new_heads.clone()).await? {
278
        Plan::To(heads, _) => heads,
279
        Plan::UpToDate => return Ok(Bumped::UpToDate),
280
        Plan::Unknown => return Ok(Bumped::Unknown),
281
        Plan::Conflicts => return Ok(Bumped::Conflicts),
282
    };
1d634f69 Andy McCall ↶ 283
    if let Some(Pin::Oxy { heads: b, .. }) = &bumped {
284
        let (mut b, mut n) = (b.clone(), new_heads.clone());
285
        b.sort();
286
        n.sort();
287
        if b == n {
9aaa88b2 Andy McCall ↶ 288
            return Ok(Bumped::Already { from: old, to: new_heads });
1d634f69 Andy McCall ↶ 289
        }
290
    }
291
    let (key, secret) =
292
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?;
293
    let identity = app.hub_key.identity.clone();
294
    let upstream_label = format!("{}/{}", upstream.owner, upstream.name);
295
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
296
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
a7e6e8e6 Andy McCall ↶ 297
    let old = old.clone();
1d634f69 Andy McCall ↶ 298
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
299
    let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+"));
300
    app.repos
301
        .write(&cname, move |r, _| {
302
            let base = heads_of(r, &t)?;
303
            let change = r.build_pin_change(&base, &p, &pin, &message, key)?;
304
            let hash = change.hash();
305
            let sig = secret.sign(Domain::Change, &key, &hash);
306
            let mut state = r.load_state(&base)?;
307
            state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
308
            let mut candidates = base.clone();
309
            candidates.push(hash);
310
            let heads = state.maximal(&candidates);
311
            let bookmark = bump_bookmark(&p);
312
            // The bump bookmark is the hub's own and is rebuilt on the
313
            // target each time, so the bump it replaces may go.
314
            let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?;
315
            update.force = true;
f01cb865 Andy McCall ↶ 316
            let state = update.new_state.to_hex();
1d634f69 Andy McCall ↶ 317
            let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?;
318
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
319
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
320
                    "the bump was refused: {}",
321
                    refused.reason
322
                ))));
323
            }
f01cb865 Andy McCall ↶ 324
            Ok(Bumped::Moved { bookmark, change: hash, heads, from: old, state })
1d634f69 Andy McCall ↶ 325
        })
326
        .await
327
}
328
a7e6e8e6 Andy McCall ↶ 329
/// The account Diffusion's proposals are opened as.
330
pub const ACCOUNT: &str = "diffusion";
331
332
/// The `diffusion` account, made on first use with no way to sign in.
333
pub async fn account(pool: &Pool) -> Option<Account> {
334
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
335
        return Some(a);
336
    }
337
    db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok()
338
}
339
c4e432da Andy McCall ↶ 340
/// Whether `path` matches a `watch` pattern: `*` is any run of characters
341
/// within one path segment, `**` any run across segments, `?` one
342
/// character; a pattern without a wildcard also matches everything below
343
/// it, as a directory would.
344
pub fn watched(pattern: &str, path: &str) -> bool {
345
    fn m(p: &[u8], s: &[u8]) -> bool {
346
        match (p.first(), s.first()) {
347
            (None, None) => true,
348
            (Some(b'*'), _) if p.get(1) == Some(&b'*') => (0..=s.len()).any(|i| m(&p[2..], &s[i..])),
349
            (Some(b'*'), _) => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| m(&p[1..], &s[i..])),
350
            (Some(b'?'), Some(c)) if *c != b'/' => m(&p[1..], &s[1..]),
351
            (Some(a), Some(b)) if a == b => m(&p[1..], &s[1..]),
352
            _ => false,
353
        }
354
    }
355
    let p = pattern.trim().trim_matches('/');
356
    if p.is_empty() {
357
        return false;
358
    }
359
    m(p.as_bytes(), path.as_bytes()) || (!p.contains(['*', '?']) && path.starts_with(&format!("{p}/")))
360
}
361
362
/// What a bump brings: the upstream changes in `new` and not in `old`,
363
/// newest first, as Markdown lines linking to the upstream's change pages,
364
/// with those touching a `watch` pattern flagged (and listed again).
365
pub struct Changelog {
366
    pub lines: Vec<String>,
367
    pub watched: Vec<String>,
368
}
369
370
pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>, watch: Vec<String>) -> Changelog {
371
    let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Changelog { lines: Vec::new(), watched: Vec::new() } };
a7e6e8e6 Andy McCall ↶ 372
    let linked: std::collections::HashMap<String, String> =
373
        db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
374
    let base = format!("/{}/{}", upstream.owner, upstream.name);
375
    app.repos
376
        .read(&name, move |r, cache| {
377
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
c4e432da Andy McCall ↶ 378
            let mut added: Vec<(u64, ChangeHash, String, String, Vec<String>)> = Vec::new();
a7e6e8e6 Andy McCall ↶ 379
            for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) {
380
                let Ok(c) = r.store().get_change(h) else { continue };
381
                let key = c.author.to_hex();
382
                let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string());
383
                let first = c.message.lines().next().unwrap_or("").trim().to_string();
c4e432da Andy McCall ↶ 384
                // The watched paths it touches, when the game watches any.
385
                let hits: Vec<String> = if watch.is_empty() {
386
                    Vec::new()
387
                } else {
388
                    crate::repos::view::change(r, cache, h)
389
                        .map(|d| d.files.into_iter().map(|f| f.path).filter(|p| watch.iter().any(|w| watched(w, p))).collect())
390
                        .unwrap_or_default()
391
                };
392
                added.push((c.hlc.wall_ms, *h, first, who, hits));
a7e6e8e6 Andy McCall ↶ 393
            }
394
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
c4e432da Andy McCall ↶ 395
            let line = |h: &ChangeHash, msg: &str, who: &str, flag: bool| {
396
                format!(
397
                    "- {}[`{}`]({base}/change/{}) {} ({who})",
398
                    if flag { "⚑ " } else { "" },
399
                    &h.to_hex()[..12],
400
                    h.to_hex(),
401
                    msg.replace('\n', " ")
402
                )
403
            };
404
            let watched = added
405
                .iter()
406
                .filter(|a| !a.4.is_empty())
407
                .map(|(_, h, msg, who, hits)| {
408
                    format!(
409
                        "{} — touches {}",
410
                        line(h, msg, who, true),
411
                        hits.iter().map(|p| format!("`{p}`")).collect::<Vec<_>>().join(", ")
412
                    )
a7e6e8e6 Andy McCall ↶ 413
                })
c4e432da Andy McCall ↶ 414
                .collect();
415
            let lines = added.iter().map(|(_, h, msg, who, hits)| line(h, msg, who, !hits.is_empty())).collect();
416
            Ok(Changelog { lines, watched })
a7e6e8e6 Andy McCall ↶ 417
        })
418
        .await
c4e432da Andy McCall ↶ 419
        .unwrap_or(Changelog { lines: Vec::new(), watched: Vec::new() })
a7e6e8e6 Andy McCall ↶ 420
}
421
9aaa88b2 Andy McCall ↶ 422
/// Why a bump was made, which its proposal says.
423
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
424
pub enum Why {
425
    /// The upstream's state passed its checks.
426
    Green,
427
    /// The upstream moved (the consumer follows every move).
428
    Moved,
429
    /// The upstream has no checks at all: nothing vouched for it, so the
430
    /// proposal is marked amber, `upstream unverified`.
431
    Unverified,
432
}
433
434
/// Open the bump proposal for `path`'s bump bookmark, or bring the open
435
/// one up to date: into `target`, by the `diffusion` account, labelled
436
/// `dependencies` (and `upstream unverified` while that is so), its body
437
/// the changelog from the pin `from` to `to`. Its number.
a7e6e8e6 Andy McCall ↶ 438
pub async fn propose(
439
    app: &Shared,
440
    consumer: &RepoRow,
441
    target: &str,
442
    path: &str,
443
    upstream: &RepoRow,
9aaa88b2 Andy McCall ↶ 444
    (from, to): (Vec<ChangeHash>, Vec<ChangeHash>),
c4e432da Andy McCall ↶ 445
    (why, watch): (Why, Vec<String>),
a7e6e8e6 Andy McCall ↶ 446
) -> Option<i64> {
447
    use crate::db::proposals as pdb;
448
    let bookmark = bump_bookmark(path);
449
    let bookmark = bookmark.as_str();
450
    let me = account(&app.db).await?;
c4e432da Andy McCall ↶ 451
    let log = changelog(app, upstream, from, to.clone(), watch.clone()).await;
452
    let lines = log.lines;
453
    let flagged = if log.watched.is_empty() {
454
        String::new()
455
    } else {
456
        format!(
457
            "\n\n**Touches what you watch** ({}):\n\n{}",
458
            watch.iter().map(|w| format!("`{w}`")).collect::<Vec<_>>().join(", "),
459
            log.watched.join("\n")
460
        )
461
    };
a7e6e8e6 Andy McCall ↶ 462
    let slug = format!("{}/{}", upstream.owner, upstream.name);
463
    let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect();
1b6536f0 Andy McCall ↶ 464
    let ub = &upstream.default_bookmark;
465
    let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) };
466
    let title = format!("Diffusion: {path} to {slug} {ub} ({count})");
9aaa88b2 Andy McCall ↶ 467
    let when = match why {
468
        Why::Green => "at its newest state whose checks passed",
469
        Why::Moved => "as it is now",
470
        Why::Unverified => "as it is now",
471
    };
472
    let amber = if why == Why::Unverified {
473
        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.")
474
    } else {
475
        String::new()
476
    };
a7e6e8e6 Andy McCall ↶ 477
    let body = format!(
c4e432da Andy McCall ↶ 478
        "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). It never lands by itself.",
1b6536f0 Andy McCall ↶ 479
        short.join("+"),
a7e6e8e6 Andy McCall ↶ 480
        lines.join("\n")
481
    );
482
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
9aaa88b2 Andy McCall ↶ 483
    let (number, id) = match open {
a7e6e8e6 Andy McCall ↶ 484
        Some(p) => {
9aaa88b2 Andy McCall ↶ 485
            if p.title != title || p.body != body {
486
                pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
487
                pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &short.join("+")).await.ok();
488
            }
489
            (p.number, p.id)
a7e6e8e6 Andy McCall ↶ 490
        }
491
        None => {
492
            let p = pdb::create(
493
                &app.db,
494
                pdb::NewProposal {
495
                    repo_id: consumer.id,
496
                    title: &title,
497
                    body: &body,
498
                    author_id: Some(me.id),
499
                    author: ACCOUNT,
500
                    draft: false,
501
                    target,
502
                    source_kind: "bookmark",
503
                    source_repo_id: None,
504
                    source_bookmark: bookmark,
505
                    source_changes: "",
506
                },
507
            )
508
            .await
509
            .ok()?;
510
            pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok();
511
            db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok();
512
            if let Ok(label) =
513
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
514
            {
515
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
516
            }
9aaa88b2 Andy McCall ↶ 517
            (p.number, p.id)
a7e6e8e6 Andy McCall ↶ 518
        }
519
    };
9aaa88b2 Andy McCall ↶ 520
    // Amber while the upstream has no checks; cleared by a green bump.
521
    let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten();
522
    match (why == Why::Unverified, unverified) {
523
        (true, l) => {
524
            let l = match l {
525
                Some(l) => Some(l),
526
                None => db::issues::upsert_label(
527
                    &app.db,
528
                    consumer.id,
529
                    "upstream unverified",
530
                    "#e3a008",
531
                    "The upstream has no checks (Diffusion)",
532
                )
533
                .await
534
                .ok(),
535
            };
536
            if let Some(l) = l {
537
                db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok();
538
            }
539
        }
540
        (false, Some(l)) => {
541
            db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok();
542
        }
543
        (false, None) => {}
544
    }
a7e6e8e6 Andy McCall ↶ 545
    Some(number)
546
}
547
f01cb865 Andy McCall ↶ 548
/// Start a bump's pipeline, as a push of it would. Boxed as `Send`, which
549
/// breaks the cycle between a bump starting runs and a passing run
550
/// starting bumps that the compiler cannot see through.
551
fn start_runs(
552
    app: Shared,
553
    row: RepoRow,
554
    bookmark: String,
555
    state: String,
556
    heads: Vec<ChangeHash>,
557
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
558
    Box::pin(async move { crate::flow::on_push(&app, &row, &bookmark, &state, &heads, ACCOUNT).await })
559
}
560
a7e6e8e6 Andy McCall ↶ 561
/// The text of `.oxymodules` on `bookmark` of `name`, if any.
562
async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> {
563
    let b = bookmark.to_string();
564
    app.repos
565
        .read(name, move |r, cache| {
566
            let heads = heads_of(r, &b)?;
567
            if heads.is_empty() {
568
                return Ok(None);
569
            }
570
            let loaded = cache.load(r, &heads)?;
571
            Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) {
572
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
573
                _ => None,
574
            })
575
        })
576
        .await
577
        .ok()
578
        .flatten()
579
}
580
581
/// The upstream `upstream` reached `heads` on its default bookmark: by a
582
/// push (`green` false) or a passing run of that state (`green` true).
583
/// Bump and propose in every consumer that follows it that way (§4.3).
584
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
3776b70d Andy McCall ↶ 585
    propagate_to(app, upstream, heads, green, None).await
586
}
587
588
/// `propagate`, to one consumer only when `only` names it.
589
async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) {
a7e6e8e6 Andy McCall ↶ 590
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
591
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
592
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?")
593
        .bind(hex::encode(id))
594
        .fetch_all(&app.db)
595
        .await
596
        .unwrap_or_default();
9aaa88b2 Andy McCall ↶ 597
    let unverified = unverified(app, upstream).await;
a7e6e8e6 Andy McCall ↶ 598
    for (consumer_id, indexed, path) in rows {
3776b70d Andy McCall ↶ 599
        if only.is_some_and(|o| o != consumer_id) {
600
            continue;
601
        }
a7e6e8e6 Andy McCall ↶ 602
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
603
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
604
        let learned = target(&app.db, &consumer).await;
605
        let modules = oxymodules(app, &cname, &learned).await;
606
        let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path);
607
        let target = r.target.clone().unwrap_or(learned);
608
        if indexed != target {
609
            continue;
610
        }
9aaa88b2 Andy McCall ↶ 611
        // An upstream with no checks never turns green: its moves count
612
        // for green followers too, marked amber (unverified).
a7e6e8e6 Andy McCall ↶ 613
        let go = match r.follow {
614
            Follow::Off => false,
9aaa88b2 Andy McCall ↶ 615
            Follow::Green => green || unverified,
a7e6e8e6 Andy McCall ↶ 616
            Follow::Any => !green,
617
        };
9aaa88b2 Andy McCall ↶ 618
        let why = if green {
619
            Why::Green
620
        } else if unverified {
621
            Why::Unverified
622
        } else {
623
            Why::Moved
624
        };
a7e6e8e6 Andy McCall ↶ 625
        if !go {
626
            continue;
627
        }
628
        match bump(app, &consumer, &target, &path, upstream, heads.clone()).await {
f01cb865 Andy McCall ↶ 629
            Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => {
630
                // Its pipeline runs as for a push, so the proposal is tested.
631
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
c4e432da Andy McCall ↶ 632
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), (why, r.watch.clone())).await;
a7e6e8e6 Andy McCall ↶ 633
                tracing::info!(
634
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
635
                    consumer.owner,
636
                    consumer.name,
637
                    upstream.owner,
638
                    upstream.name
639
                );
9aaa88b2 Andy McCall ↶ 640
            }
641
            Ok(Bumped::Already { from, to }) => {
642
                // Already moved: its proposal's text and labels are brought
643
                // up to date all the same.
c4e432da Andy McCall ↶ 644
                propose(app, &consumer, &target, &path, upstream, (from, to), (why, r.watch.clone())).await;
a7e6e8e6 Andy McCall ↶ 645
            }
7a14bdae Andy McCall ↶ 646
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
a7e6e8e6 Andy McCall ↶ 647
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
3776b70d Andy McCall ↶ 648
        }
649
    }
9aaa88b2 Andy McCall ↶ 650
}
651
652
/// Whether `upstream` has no checks of its own: Flow is off for it, or
653
/// its default bookmark has never had a run. Its moves are then
654
/// "unverified" (amber), never green.
655
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
656
    if !crate::flow::enabled(app, upstream) {
657
        return true;
658
    }
659
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND bookmark = ?")
660
        .bind(upstream.id)
661
        .bind(&upstream.default_bookmark)
662
        .fetch_one(&app.db)
663
        .await
664
        .unwrap_or(0);
665
    runs == 0
3776b70d Andy McCall ↶ 666
}
667
668
/// The repository on this hub with Oxygen id `id` (hex).
669
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
670
    for row in db::repos::list(&app.db).await.ok()? {
671
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
672
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
673
            return Some(row);
674
        }
675
    }
676
    None
677
}
678
679
/// The newest state of `upstream`'s default bookmark whose run passed.
680
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
681
    let heads: String =
682
        sqlx::query_scalar("SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 1")
683
            .bind(upstream.id)
684
            .bind(&upstream.default_bookmark)
685
            .fetch_optional(&app.db)
686
            .await
687
            .ok()
688
            .flatten()?;
689
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
690
    (!h.is_empty()).then_some(h)
691
}
692
693
/// Bring `consumer` up to date with the upstreams it pins, at once: when
694
/// it turns Diffusion on, it is bumped to each upstream's newest state
695
/// that passed (or simply its newest, when it follows every move).
696
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
697
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = ?")
698
        .bind(consumer.id)
699
        .fetch_all(&app.db)
700
        .await
701
        .unwrap_or_default();
702
    for id in upstreams {
703
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
704
        if let Some(h) = last_green(app, &upstream).await {
705
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
706
        }
707
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
708
        let b = upstream.default_bookmark.clone();
709
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
710
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
a7e6e8e6 Andy McCall ↶ 711
        }
712
    }
713
}
714
e7158350 Andy McCall ↶ 715
/// When a consumer takes an upstream's moves (§4.2).
716
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
717
#[serde(rename_all = "lowercase")]
718
pub enum Follow {
719
    Off,
720
    /// When the upstream state's checks pass.
721
    Green,
722
    /// On every move.
723
    Any,
724
}
725
726
impl Follow {
727
    pub fn parse(s: &str) -> Option<Follow> {
728
        match s.trim() {
729
            "off" => Some(Follow::Off),
730
            "green" => Some(Follow::Green),
731
            "any" => Some(Follow::Any),
732
            _ => None,
733
        }
734
    }
735
}
736
737
/// How one consumer follows one subrepo, from its `.oxymodules` entry, its
738
/// repository setting and the site's default, in that order.
739
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
740
pub struct Rule {
741
    pub follow: Follow,
742
    /// Land a green bump itself (D5); D1 only proposes.
743
    pub land_auto: bool,
744
    /// The bookmark to bump, when `.oxymodules` names one (else learned).
745
    pub target: Option<String>,
746
    /// Paths the consumer watches: upstream changes touching them are
747
    /// flagged in a bump's changelog.
748
    pub watch: Vec<String>,
749
    /// Where `follow` came from: `.oxymodules`, `setting` or `site`.
750
    pub from: &'static str,
751
}
752
753
/// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`,
754
/// `land`, `target` and `watch` (comma-separated, or a TOML list).
755
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
756
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
757
    let mut here = false;
758
    for line in text.lines() {
759
        let line = line.trim();
760
        if line.is_empty() || line.starts_with('#') {
761
            continue;
762
        }
763
        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
764
            here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/');
765
            continue;
766
        }
767
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
768
        let v = v.trim();
769
        let unquoted = v.trim_matches('"').trim();
770
        match k.trim() {
771
            "follow" => follow = Follow::parse(unquoted),
772
            "land" => land = Some(unquoted == "auto"),
773
            "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()),
774
            "watch" => {
775
                watch = v
776
                    .trim_start_matches('[')
777
                    .trim_end_matches(']')
778
                    .split(',')
779
                    .map(|w| w.trim().trim_matches('"').trim().to_string())
780
                    .filter(|w| !w.is_empty())
781
                    .collect()
782
            }
783
            _ => {}
784
        }
785
    }
786
    (follow, land, target, watch)
787
}
788
789
/// How `row` follows its subrepo at `path`, given its `.oxymodules` text.
790
pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule {
791
    let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default();
792
    let (follow, from) = match follow {
793
        Some(f) => (f, ".oxymodules"),
794
        None => match Follow::parse(&row.diffusion) {
795
            Some(f) => (f, "setting"),
796
            None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"),
797
        },
798
    };
799
    Rule { follow, land_auto: land.unwrap_or(false), target, watch, from }
800
}
801
802
#[cfg(test)]
803
mod tests {
804
    use super::*;
805
806
    const MODULES: &str = r#"
807
[rstar-api]
808
url = https://oxygenhub.io/doomsdayone/rstar-api
809
follow = green
810
land = "propose"
811
target = develop
812
watch = ["src/x16/irq*", "src/x16/frame*"]
813
814
[other]
815
url = https://example/other
816
follow = any
817
"#;
818
819
    fn row(diffusion: &str) -> RepoRow {
820
        RepoRow {
821
            id: 1,
822
            owner: "o".into(),
823
            name: "n".into(),
824
            description: String::new(),
825
            default_bookmark: "main".into(),
826
            visibility: "public".into(),
827
            created_ms: 0,
828
            updated_ms: 0,
829
            git_mirror: false,
830
            git_mirror_state: String::new(),
831
            forked_from: None,
832
            topics: String::new(),
833
            flow: String::new(),
834
            diffusion: diffusion.into(),
835
            ..Default::default()
836
        }
c4e432da Andy McCall ↶ 837
    }
838
839
    #[test]
840
    fn watch_patterns_match_paths() {
841
        assert!(watched("src/x16/irq*", "src/x16/irq.s"));
842
        assert!(watched("src/x16/irq*", "src/x16/irq_wait.c"));
843
        assert!(!watched("src/x16/irq*", "src/x16/sub/irq.s"), "one segment");
844
        assert!(watched("src/**/frame*.c", "src/x16/floor/frame_wait.c"));
845
        assert!(watched("src/x16", "src/x16/bank.s"), "a directory");
846
        assert!(!watched("src/x16", "src/x160/bank.s"));
847
        assert!(watched("include/r?m.h", "include/rim.h"));
848
        assert!(!watched("", "anything"));
e7158350 Andy McCall ↶ 849
    }
850
851
    #[test]
852
    fn oxymodules_keys_are_read_per_entry() {
853
        let (f, land, target, watch) = module_keys(MODULES, "rstar-api");
854
        assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop")));
855
        assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]);
856
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
857
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
858
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
859
    }
860
861
    #[test]
862
    fn oxymodules_wins_then_the_setting_then_the_site() {
863
        let r = rule("off", &row("any"), Some(MODULES), "rstar-api");
864
        assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules"));
865
        let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api");
866
        assert_eq!((r.follow, r.from), (Follow::Any, "setting"));
867
        let r = rule("green", &row(""), None, "rstar-api");
868
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
869
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
870
    }
871
}