Turn OxygenFlow on or off per repository in settings 9008bf6c2b8c
Site administrators switch OxygenFlow on, off or back to the site default under a repository's Settings → OxygenFlow; hub.toml's [flow] repos stays the default and no restart is needed. Repository admins see whether pushes start runs but cannot change it, since runs use the site's runners. The Flow tab's "not enabled" note links to the setting. Audited as repo.flow. Migration 0018 adds repositories.flow. closes #22
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 21:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9008bf6c2b8cab8411441fc4103ce3e93d9a3ccd68af20d0a7e78ab4c244a119- Depends on
- 07b1a5bbf481 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 3f4b0ea6eabf 42fa95540468 64caed18236a 93be9ce4653e 941abdd6bb4c 961c10bee174 b64e2537d702 d23464c0ac60
- Depended on by
- 40a43921ef42 166f6e9bf4f1 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb 16ee5d19d220 8dcaeeb848c6 53a0fb2e4ac6 107eaba52c9d c8684d44d549 5d90e0e045e2 1870e4fa9808 4a4400c5812d d3b048b27277 f4d6ef418a96 e71583501b1d 7edaff01d506 1f78655a0466 d0fbea2eb46e 4ac01a732552 2a83f5303458 e71846aa07b9 c889911bec12 7974adb0be66 4f6925337848 bf3d07541368 1edac4b68c6d e271a37b629a a0d4bbfbedd1
- 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.1.0 v0.2.0 v0.3.0 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
- 21 atoms, 1 tree operation, 11 files
Browse the tree at this change
added crates/hub/migrations/0018_flow_setting.sql +2 −0
| 1 | + | -- OxygenFlow per repository: 'on', 'off', or '' to follow hub.toml's [flow] repos. |
|
| 2 | + | ALTER TABLE repositories ADD COLUMN flow TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/db/repos.rs +9 −0
| … | 23 unchanged lines |
||
| 24 | 24 | /// Space-separated topics (migration 0010). |
|
| 25 | 25 | #[sqlx(default)] |
|
| 26 | 26 | pub topics: String, |
|
| 27 | + | /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018). |
|
| 28 | + | #[sqlx(default)] |
|
| 29 | + | pub flow: String, |
|
| 27 | 30 | /// Counts for the tabs; filled by `get` only. |
|
| 28 | 31 | #[sqlx(default)] |
|
| 29 | 32 | pub open_issues: i64, |
|
| … | 206 unchanged lines |
||
| 236 | 239 | /// The forks of `id`, newest activity first. |
|
| 237 | 240 | pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> { |
|
| 238 | 241 | sqlx::query_as("SELECT * FROM repositories WHERE forked_from = ? ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). |
|
| 245 | + | pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 246 | + | sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; |
|
| 247 | + | Ok(()) |
|
| 239 | 248 | } |
|
| 240 | 249 | ||
| 241 | 250 | pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { |
|
| … | 23 unchanged lines |
modified crates/hub/src/flow.rs +11 −1
| … | 18 unchanged lines |
||
| 19 | 19 | /// A job token is refused this long after its job started, whatever its state. |
|
| 20 | 20 | pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000; |
|
| 21 | 21 | ||
| 22 | + | /// Whether pushes to this repository start runs: its own setting when a |
|
| 23 | + | /// site administrator made one, else hub.toml's `[flow] repos`. |
|
| 24 | + | pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool { |
|
| 25 | + | match row.flow.as_str() { |
|
| 26 | + | "on" => true, |
|
| 27 | + | "off" => false, |
|
| 28 | + | _ => app.config.flow.allows(&row.owner, &row.name), |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 22 | 32 | /// A bookmark moved by a push: start a run when the repository is allowed |
|
| 23 | 33 | /// and the new state has a pipeline. |
|
| 24 | 34 | pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) { |
|
| 25 | - | if bookmark.starts_with("tags/") || heads.is_empty() || !app.config.flow.allows(&row.owner, &row.name) { |
|
| 35 | + | if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) { |
|
| 26 | 36 | return; |
|
| 27 | 37 | } |
|
| 28 | 38 | let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| … | 314 unchanged lines |
modified crates/hub/src/web/flow.rs +1 −1
| … | 53 unchanged lines |
||
| 54 | 54 | }; |
|
| 55 | 55 | runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r }); |
|
| 56 | 56 | } |
|
| 57 | - | let enabled = app.config.flow.allows(&row.owner, &row.name); |
|
| 57 | + | let enabled = crate::flow::enabled(&app, &row); |
|
| 58 | 58 | Ok(Page(RunsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), runs, enabled, older })) |
|
| 59 | 59 | } |
|
| 60 | 60 | ||
| … | 165 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 157 unchanged lines |
||
| 158 | 158 | .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack)) |
|
| 159 | 159 | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 160 | 160 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 161 | + | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 161 | 162 | .route("/{owner}/{name}/issues", get(issues::list)) |
|
| 162 | 163 | .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create)) |
|
| 163 | 164 | .route("/{owner}/{name}/issue/{number}", get(issues::show)) |
|
| … | 413 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +63 −1
| … | 88 unchanged lines |
||
| 89 | 89 | requirements: db::proposals::Requirements, |
|
| 90 | 90 | hooks: Vec<super::webhooks::HookRow>, |
|
| 91 | 91 | required_checks: String, |
|
| 92 | + | /// Whether hub.toml alone would run Flow here, and whether it runs. |
|
| 93 | + | flow_default: bool, |
|
| 94 | + | flow_enabled: bool, |
|
| 95 | + | site_admin: bool, |
|
| 92 | 96 | notice: Option<String>, |
|
| 93 | 97 | error: Option<String>, |
|
| 94 | 98 | } |
|
| … | 16 unchanged lines |
||
| 111 | 115 | let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 112 | 116 | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 113 | 117 | let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", "); |
|
| 114 | - | Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, notice, error })) |
|
| 118 | + | let flow_default = app.config.flow.allows(&row.owner, &row.name); |
|
| 119 | + | let flow_enabled = crate::flow::enabled(app, &row); |
|
| 120 | + | let site_admin = shell.is_admin; |
|
| 121 | + | Ok(Page(RepoSettingsPage { |
|
| 122 | + | shell, |
|
| 123 | + | repo, |
|
| 124 | + | row, |
|
| 125 | + | collaborators, |
|
| 126 | + | teams, |
|
| 127 | + | role, |
|
| 128 | + | requirements, |
|
| 129 | + | hooks, |
|
| 130 | + | required_checks, |
|
| 131 | + | flow_default, |
|
| 132 | + | flow_enabled, |
|
| 133 | + | site_admin, |
|
| 134 | + | notice, |
|
| 135 | + | error, |
|
| 136 | + | })) |
|
| 115 | 137 | } |
|
| 116 | 138 | ||
| 117 | 139 | pub async fn settings( |
|
| … | 245 unchanged lines |
||
| 363 | 385 | csrf_token: String, |
|
| 364 | 386 | #[serde(default)] |
|
| 365 | 387 | enabled: Option<String>, |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | #[derive(Deserialize)] |
|
| 391 | + | pub struct FlowForm { |
|
| 392 | + | #[serde(rename = "_csrf")] |
|
| 393 | + | csrf_token: String, |
|
| 394 | + | /// `on`, `off`, or `default`. |
|
| 395 | + | flow: String, |
|
| 396 | + | } |
|
| 397 | + | ||
| 398 | + | /// Turn OxygenFlow on or off for one repository. Runs use the site's |
|
| 399 | + | /// runners, so only site administrators decide. |
|
| 400 | + | pub async fn flow( |
|
| 401 | + | State(app): State<Shared>, |
|
| 402 | + | viewer: Viewer, |
|
| 403 | + | Path((owner, name)): Path<(String, String)>, |
|
| 404 | + | Form(f): Form<FlowForm>, |
|
| 405 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 406 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 407 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 408 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 409 | + | if !viewer.is_admin() { |
|
| 410 | + | return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await; |
|
| 411 | + | } |
|
| 412 | + | let setting = match f.flow.as_str() { |
|
| 413 | + | "on" => "on", |
|
| 414 | + | "off" => "off", |
|
| 415 | + | "default" => "", |
|
| 416 | + | _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await, |
|
| 417 | + | }; |
|
| 418 | + | db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 419 | + | let label = if setting.is_empty() { "default" } else { setting }; |
|
| 420 | + | accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok(); |
|
| 421 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 422 | + | let notice = if crate::flow::enabled(&app, &row) { |
|
| 423 | + | "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs." |
|
| 424 | + | } else { |
|
| 425 | + | "OxygenFlow is off for this repository." |
|
| 426 | + | }; |
|
| 427 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 366 | 428 | } |
|
| 367 | 429 | ||
| 368 | 430 | pub async fn mirror( |
|
| … | 20 unchanged lines |
modified crates/hub/tests/api.rs +55 −0
| … | 701 unchanged lines |
||
| 702 | 702 | // The settings page shows the same list. |
|
| 703 | 703 | assert!(call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test")); |
|
| 704 | 704 | } |
|
| 705 | + | ||
| 706 | + | /// OxygenFlow per repository: site administrators turn it on or off in |
|
| 707 | + | /// the repository's settings; hub.toml's `[flow] repos` is the default. |
|
| 708 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 709 | + | async fn flow_is_switched_per_repository_by_site_admins() { |
|
| 710 | + | let h = hub().await; |
|
| 711 | + | let app = &h.app; |
|
| 712 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 713 | + | .await |
|
| 714 | + | .cookie |
|
| 715 | + | .unwrap(); |
|
| 716 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 717 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 718 | + | let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| 719 | + | assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos"); |
|
| 720 | + | let tab = call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await; |
|
| 721 | + | assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings#flow")); |
|
| 722 | + | ||
| 723 | + | let set = |value: &'static str| { |
|
| 724 | + | let (app, andy) = (app.clone(), andy.clone()); |
|
| 725 | + | async move { |
|
| 726 | + | let settings = call(&app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await; |
|
| 727 | + | call( |
|
| 728 | + | &app, |
|
| 729 | + | "POST", |
|
| 730 | + | "/andy/demo/settings/flow", |
|
| 731 | + | None, |
|
| 732 | + | Some(&andy), |
|
| 733 | + | None, |
|
| 734 | + | Some(&format!("_csrf={}&flow={value}", csrf_of(&settings.body))), |
|
| 735 | + | ) |
|
| 736 | + | .await |
|
| 737 | + | } |
|
| 738 | + | }; |
|
| 739 | + | // A repository admin who is not a site admin sees the state but cannot change it. |
|
| 740 | + | let id = oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 741 | + | oxygenhub::db::accounts::set_admin(&app.db, id, false).await.unwrap(); |
|
| 742 | + | let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await; |
|
| 743 | + | assert!(settings.body.contains("do not start runs") && !settings.body.contains("name=\"flow\"")); |
|
| 744 | + | let r = set("on").await; |
|
| 745 | + | assert!(r.body.contains("Only site administrators"), "{}", r.body); |
|
| 746 | + | assert_eq!(row().await.flow, ""); |
|
| 747 | + | ||
| 748 | + | oxygenhub::db::accounts::set_admin(&app.db, id, true).await.unwrap(); |
|
| 749 | + | let r = set("on").await; |
|
| 750 | + | assert!(r.body.contains("OxygenFlow is on"), "{}", r.body); |
|
| 751 | + | assert_eq!(row().await.flow, "on"); |
|
| 752 | + | assert!(oxygenhub::flow::enabled(app, &row().await)); |
|
| 753 | + | assert!(!call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await.body.contains("not enabled")); |
|
| 754 | + | set("off").await; |
|
| 755 | + | assert!(!oxygenhub::flow::enabled(app, &row().await)); |
|
| 756 | + | set("default").await; |
|
| 757 | + | assert_eq!(row().await.flow, ""); |
|
| 758 | + | assert!(set("sometimes").await.body.contains("Pick on, off")); |
|
| 759 | + | } |
modified crates/hub/tests/flow.rs +22 −0
| … | 442 unchanged lines |
||
| 443 | 443 | let seqs: std::collections::BTreeSet<i64> = lines.iter().map(|l| l.seq).collect(); |
|
| 444 | 444 | assert_eq!(seqs.len(), 320, "sequence numbers are unique"); |
|
| 445 | 445 | } |
|
| 446 | + | ||
| 447 | + | /// A repository's own Flow setting overrides hub.toml either way. |
|
| 448 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 449 | + | async fn a_repository_setting_turns_flow_on_or_off() { |
|
| 450 | + | let h = hub().await; |
|
| 451 | + | create(&h.app, "other", "plain").await; |
|
| 452 | + | create(&h.app, "andy", "quiet").await; |
|
| 453 | + | let row = db::repos::get(&h.app.db, "other", "plain").await.unwrap().unwrap(); |
|
| 454 | + | db::repos::set_flow(&h.app.db, row.id, "on").await.unwrap(); |
|
| 455 | + | let quiet = db::repos::get(&h.app.db, "andy", "quiet").await.unwrap().unwrap(); |
|
| 456 | + | db::repos::set_flow(&h.app.db, quiet.id, "off").await.unwrap(); |
|
| 457 | + | for (owner, name) in [("other", "plain"), ("andy", "quiet")] { |
|
| 458 | + | let work = tempfile::tempdir().unwrap(); |
|
| 459 | + | let c = client(&format!("{}/{owner}/{name}", h.base), work.path()); |
|
| 460 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 461 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 462 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 463 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 464 | + | } |
|
| 465 | + | assert_eq!(runs_of(&h.app, "other", "plain").await.len(), 1, "turned on"); |
|
| 466 | + | assert!(runs_of(&h.app, "andy", "quiet").await.is_empty(), "turned off despite andy/*"); |
|
| 467 | + | } |
modified docs/spec.md +2 −1
| … | 416 unchanged lines |
||
| 417 | 417 | ||
| 418 | 418 | Pipelines run in **OxygenFlow**, a separate project and specification (`oxygenflow/docs/spec.md`; the first slice is `oxygenflow/docs/m0.md`). A pipeline is `.flow/pipeline.kdl` in the state being built; jobs run in Flow's isolation tiers (MicroVM by default, a syscall sandbox, WASM), never in containers on the hub. The hub's side: |
|
| 419 | 419 | ||
| 420 | - | - **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs; empty means none) and `job_timeout_minutes` (60). |
|
| 420 | + | - **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) and `job_timeout_minutes` (60). |
|
| 421 | + | - **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart. |
|
| 421 | 422 | - **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state: the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. |
|
| 422 | 423 | - **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`. |
|
| 423 | 424 | - **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it. |
|
| … | 153 unchanged lines |
modified templates/flow_runs.html +1 −1
| … | 9 unchanged lines |
||
| 10 | 10 | {% if enabled %} |
|
| 11 | 11 | <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs.</p> |
|
| 12 | 12 | {% else %} |
|
| 13 | - | <p>OxygenFlow is not enabled for this repository. A site administrator lists it under <code>[flow] repos</code> in <code>hub.toml</code>.</p> |
|
| 13 | + | <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings#flow">Settings → OxygenFlow</a>.</p> |
|
| 14 | 14 | {% endif %} |
|
| 15 | 15 | <pre class="snippet">schema-version 1 |
|
| 16 | 16 | ||
| … | 32 unchanged lines |
modified templates/repo_settings.html +17 −0
| … | 13 unchanged lines |
||
| 14 | 14 | <a href="#landing">Landing requirements</a> |
|
| 15 | 15 | <a href="#git-mirror">Git mirror</a> |
|
| 16 | 16 | <a href="#checks">Required checks</a> |
|
| 17 | + | <a href="#flow">OxygenFlow</a> |
|
| 17 | 18 | {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a> |
|
| 18 | 19 | <a href="#collaborators">Collaborators</a> |
|
| 19 | 20 | <a href="#danger">Delete</a>{% endif %} |
|
| … | 37 unchanged lines |
||
| 57 | 58 | <label for="required_checks">Check names that must pass on a proposal's projected state (comma-separated)</label><input type="text" id="required_checks" name="required_checks" value="{{ required_checks }}" placeholder="build, test"> |
|
| 58 | 59 | <p class="small mute">Any token with <code>checks:write</code> reports checks with <code>POST {{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/checks</code>.</p> |
|
| 59 | 60 | <button type="submit">SAVE</button> |
|
| 61 | + | </form> |
|
| 62 | + | <form class="card wide" id="flow" method="post" action="{{ repo.url }}/settings/flow"> |
|
| 63 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 64 | + | <h2>OxygenFlow</h2> |
|
| 65 | + | <p>Pushes to this repository {% if flow_enabled %}<strong>start runs</strong>{% else %}<strong>do not start runs</strong>{% endif %} of its <code>.flow/pipeline.kdl</code>.</p> |
|
| 66 | + | {% if site_admin %} |
|
| 67 | + | <div class="choice"> |
|
| 68 | + | <label><input type="radio" name="flow" value="on"{% if row.flow == "on" %} checked{% endif %}> on</label> |
|
| 69 | + | <label><input type="radio" name="flow" value="off"{% if row.flow == "off" %} checked{% endif %}> off</label> |
|
| 70 | + | <label><input type="radio" name="flow" value="default"{% if row.flow.is_empty() %} checked{% endif %}> site default ({% if flow_default %}on{% else %}off{% endif %}, from <code>[flow] repos</code> in <code>hub.toml</code>)</label> |
|
| 71 | + | </div> |
|
| 72 | + | <p class="small mute">Runs use the site's runners, so only site administrators change this. It takes effect at the next push.</p> |
|
| 73 | + | <button type="submit">SAVE</button> |
|
| 74 | + | {% else %} |
|
| 75 | + | <p class="small mute">A site administrator turns OxygenFlow on or off here.</p> |
|
| 76 | + | {% endif %} |
|
| 60 | 77 | </form> |
|
| 61 | 78 | {% if role == crate::auth::Role::Admin %} |
|
| 62 | 79 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| … | 32 unchanged lines |