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 | + | } |