OXYGENHUB

Runs started by a push honour each job's scope d7ce41acf22c

The files the push's new changes touch (against the bookmark's previous
run, placed by the new state's tree) decide which scoped jobs run;
jobs a kept job depends on stay; first runs, manual runs and reruns
keep every job. Test.

closes #88
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 20:40 UTC
Signature
✔ signed by Andy McCall
Hash
d7ce41acf22c43e8dcd97c5a8f598e720a9ec655796fce64c88fd0b8a658862a
Depends on
1edac4b68c6d 3f4b0ea6eabf 42fa95540468 749d9a5fcd9f 90326c9601f9 9e50caedf4db b5668108487f b64e2537d702 d23464c0ac60 ea85398a51bc eef677c48cd1
Depended on by
5588bb3631d2 3b1bc27fe805 44a3d2ebaddd 0650d94ede82 745d225de344 348f8c4baccf c889911bec12 5dead1f4f26d
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A job's `scope` is honoured (#88).** A run a push starts leaves out each job whose `scope` globs none of the files the push's new changes touch (since the bookmark's previous run), unless a job that runs depends on it; a bookmark's first run, "Run pipeline" and reruns keep every job. A push that touches no job's business starts no run.
8 +
7 9
- **Modern form controls everywhere (#87).** Every text field, textarea and dropdown on the site is rounded and padded, rings in the accent colour on focus, and dropdowns draw their own chevron; checkboxes and radios take the accent colour. On issues and proposals, Projects is a gear menu like Labels and Reviewers instead of a dropdown and Add button.
8 10
9 11
- **A tidier proposal conversation (#86).** A pencil by the title and a ··· menu on the description (Copy link, Copy Markdown, Edit) edit the title, target and description in place, replacing the box at the foot of the page. The comment box has one action row (Back to draft, Close proposal, Comment); the review is a compact card with Comment / Approve / Request changes as one segmented control beside Submit. A landed proposal ends with a "Proposal landed" card (who, how many changes, onto which bookmark), offering to delete the source bookmark while it is still there; a closed one with a "Proposal closed" card.
…
47 unchanged lines
modified Cargo.lock +1 −0
…
2222 unchanged lines
2223 2223
 "flate2",
2224 2224
 "flow-core",
2225 2225
 "futures",
2226 +
 "globset",
2226 2227
 "hex",
2227 2228
 "hmac",
2228 2229
 "http-body-util",
…
2420 unchanged lines
modified crates/hub/Cargo.toml +2 −0
…
61 unchanged lines
62 62
lzma-rs = "0.3"
63 63
sha2 = "0.10"
64 64
md-5 = "0.10"
65 +
# A job's scope globs (oxygen#59).
66 +
globset = "0.4"
65 67
zip.workspace = true
66 68
lettre.workspace = true
67 69
regex.workspace = true
…
8 unchanged lines
modified crates/hub/src/db/flow.rs +10 −0
…
221 unchanged lines
222 222
        .await
223 223
}
224 224
225 +
/// The latest run on this bookmark of this repository, for what a push
226 +
/// changed since (a job's scope).
227 +
pub async fn latest_on(pool: &Pool, repo_id: i64, bookmark: &str) -> sqlx::Result<Option<Run>> {
228 +
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND heads != '' ORDER BY id DESC LIMIT 1")
229 +
        .bind(repo_id)
230 +
        .bind(bookmark)
231 +
        .fetch_optional(pool)
232 +
        .await
233 +
}
234 +
225 235
pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> {
226 236
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id < ? ORDER BY id DESC LIMIT ?")
227 237
        .bind(repo_id)
…
343 unchanged lines
modified crates/hub/src/flow.rs +108 −1
…
34 unchanged lines
35 35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
36 36
        return;
37 37
    }
38 -
    if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await {
38 +
    let touched = touched_since_last_run(app, row, bookmark, heads).await;
39 +
    if let Err(e) = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await {
39 40
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
40 41
    }
41 42
}
…
59 unchanged lines
101 102
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
102 103
}
103 104
105 +
/// The paths the changes a push brought touch: the changes in `heads`'
106 +
/// state that the bookmark's previous run did not have, and the files their
107 +
/// edits and tree operations name, as the new state places them. `None`
108 +
/// (run every job) for the bookmark's first run, or when it cannot be told.
109 +
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
110 +
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
111 +
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
112 +
    if old.is_empty() {
113 +
        return None;
114 +
    }
115 +
    let repo = RepoName::new(&row.owner, &row.name)?;
116 +
    let new = heads.to_vec();
117 +
    app.repos
118 +
        .read(&repo, move |r, cache| {
119 +
            let fresh = r.negotiate(&old, &new)?;
120 +
            let loaded = cache.load(r, &new)?;
121 +
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
122 +
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
123 +
            let mut paths = std::collections::BTreeSet::new();
124 +
            for h in fresh {
125 +
                let (c, _) = r.store().get_change_lazy(&h)?;
126 +
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
127 +
                for n in nodes {
128 +
                    if let Some(p) = by_node.get(&n) {
129 +
                        paths.insert((*p).clone());
130 +
                    }
131 +
                }
132 +
            }
133 +
            Ok(paths.into_iter().collect::<Vec<String>>())
134 +
        })
135 +
        .await
136 +
        .ok()
137 +
}
138 +
139 +
/// Whether a job runs for a push that touched `touched`: always without a
140 +
/// scope or without knowing what was touched, else when a path matches.
141 +
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
142 +
    let Some(touched) = touched else { return true };
143 +
    if scope.is_empty() {
144 +
        return true;
145 +
    }
146 +
    let mut b = globset::GlobSetBuilder::new();
147 +
    for g in scope {
148 +
        match globset::Glob::new(g) {
149 +
            Ok(g) => {
150 +
                b.add(g);
151 +
            }
152 +
            Err(_) => return true,
153 +
        }
154 +
    }
155 +
    let Ok(set) = b.build() else { return true };
156 +
    touched.iter().any(|p| set.is_match(p))
157 +
}
158 +
159 +
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
160 +
/// left out of the run, unless a job that runs depends on them.
161 +
#[allow(clippy::too_many_arguments)]
162 +
pub async fn start_run_scoped(
163 +
    app: &Shared,
164 +
    row: &db::repos::RepoRow,
165 +
    bookmark: &str,
166 +
    state: &str,
167 +
    heads: &[ChangeHash],
168 +
    actor: &str,
169 +
    trigger: &str,
170 +
    touched: Option<&[String]>,
171 +
) -> Result<Option<i64>, String> {
172 +
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
173 +
}
174 +
104 175
/// Start a run of the pipeline in the state `heads` make (whose state
105 176
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
106 177
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
…
9 unchanged lines
116 187
    actor: &str,
117 188
    trigger: &str,
118 189
) -> Result<Option<i64>, String> {
190 +
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
191 +
}
192 +
193 +
#[allow(clippy::too_many_arguments)]
194 +
async fn start_run_with(
195 +
    app: &Shared,
196 +
    row: &db::repos::RepoRow,
197 +
    bookmark: &str,
198 +
    state: &str,
199 +
    heads: &[ChangeHash],
200 +
    actor: &str,
201 +
    trigger: &str,
202 +
    touched: Option<&[String]>,
203 +
) -> Result<Option<i64>, String> {
119 204
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
120 205
    let h = heads.to_vec();
121 206
    let text = app
…
19 unchanged lines
141 226
        }
142 227
        Ok(p) => {
143 228
            let depths = p.depths();
229 +
            // Jobs out of the push's scope stay out, unless a kept job
230 +
            // needs them.
231 +
            let mut keep: std::collections::HashSet<String> =
232 +
                p.order().into_iter().filter(|j| in_scope(&j.scope, touched)).map(|j| j.name.clone()).collect();
233 +
            loop {
234 +
                let needed: Vec<String> = p
235 +
                    .order()
236 +
                    .into_iter()
237 +
                    .filter(|j| keep.contains(&j.name))
238 +
                    .flat_map(|j| j.depends_on.iter().cloned())
239 +
                    .filter(|d| !keep.contains(d))
240 +
                    .collect();
241 +
                if needed.is_empty() {
242 +
                    break;
243 +
                }
244 +
                keep.extend(needed);
245 +
            }
144 246
            let jobs: Vec<fdb::NewJob> = p
145 247
                .order()
146 248
                .into_iter()
249 +
                .filter(|j| keep.contains(&j.name))
147 250
                .map(|j| {
148 251
                    let sb = &p.sandboxes[&j.sandbox];
149 252
                    fdb::NewJob {
…
6 unchanged lines
156 259
                    }
157 260
                })
158 261
                .collect();
262 +
            if jobs.is_empty() && touched.is_some() {
263 +
                // Nothing this push touched is any job's business.
264 +
                return Ok(None);
265 +
            }
159 266
            let hash = hex::encode(p.hash());
160 267
            let id =
161 268
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
…
594 unchanged lines
modified crates/hub/tests/flow.rs +42 −0
…
1441 unchanged lines
1442 1442
    let run = &runs_of(&h.app, "andy", "held").await[0];
1443 1443
    assert!(db::flow::jobs(&h.app.db, run.id).await.unwrap().iter().all(|j| j.status != "running"), "the run settles");
1444 1444
}
1445 +
1446 +
/// oxygen#59: a job with a `scope` runs on a push only when the push's
1447 +
/// changes touch a path it names (a release job, when Cargo.toml's version
1448 +
/// moves); a bookmark's first run and manual runs have every job.
1449 +
#[tokio::test(flavor = "multi_thread")]
1450 +
async fn a_scoped_job_runs_only_when_a_push_touches_its_paths() {
1451 +
    let h = hub().await;
1452 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
1453 +
    create(&h.app, "andy", "scoped").await;
1454 +
    let url = format!("{}/andy/scoped", h.base);
1455 +
    let work = tempfile::tempdir().unwrap();
1456 +
    let c = client(&url, work.path());
1457 +
    let extra = "job \"dist\" {\n    sandbox \"alpine\"\n    scope \"Cargo.toml\" \"packaging/**\"\n    exec \"true\"\n}\n";
1458 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(extra));
1459 +
    write(work.path(), "test.sh", "echo ok\n");
1460 +
    write(work.path(), "Cargo.toml", "version = \"0.1.0\"\n");
1461 +
    c.commit("Start", &[]).unwrap();
1462 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1463 +
    let names = |app: Shared, n: usize| async move {
1464 +
        let mut runs = Vec::new();
1465 +
        for _ in 0..100 {
1466 +
            runs = runs_of(&app, "andy", "scoped").await;
1467 +
            if runs.len() >= n {
1468 +
                break;
1469 +
            }
1470 +
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1471 +
        }
1472 +
        assert_eq!(runs.len(), n, "runs");
1473 +
        let mut j: Vec<String> = db::flow::jobs(&app.db, runs[0].id).await.unwrap().into_iter().map(|j| j.name).collect();
1474 +
        j.sort();
1475 +
        j
1476 +
    };
1477 +
    assert_eq!(names(h.app.clone(), 1).await, ["build", "dist", "lint", "test"], "the first run has every job");
1478 +
    write(work.path(), "test.sh", "echo still ok\n");
1479 +
    c.commit("Edit the test", &[]).unwrap();
1480 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1481 +
    assert_eq!(names(h.app.clone(), 2).await, ["build", "lint", "test"], "dist is out of scope");
1482 +
    write(work.path(), "Cargo.toml", "version = \"0.1.1\"\n");
1483 +
    c.commit("Release 0.1.1", &[]).unwrap();
1484 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1485 +
    assert_eq!(names(h.app.clone(), 3).await, ["build", "dist", "lint", "test"], "a version bump runs dist");
1486 +
}