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