OXYGENHUB

Variables for pipelines at the site, organisation and repository 2a83f5303458

Each level holds NAME=value pairs under Variables on its settings page,
showing what it inherits and what it overrides; a claim carries the
repository's effective set as env, which Flow adds to the job's
environment under FLOW_* and the pipeline's own (oxygenflow pin moved).
Not for secrets, which Flow mounts. The three levels now share
crate::levels. closes #102
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 16:02 UTC
Signature
✔ signed by Andy McCall
Hash
2a83f5303458253ca676b38c479a96a8469bc39723ac7b74e0da95b8fcfcb673
Depends on
19bb31ff1f5c 3556d08e17f9 380996db84e5 42fa95540468 5e6d6484c28e 64caed18236a 749d9a5fcd9f 7974adb0be66 7d2eb590051f 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 9a065bc0ce33 a3c8288b0141 a6c241e8eba0 b5668108487f b64e2537d702 b6b7e4cf6b24 c889911bec12 cc19697f8871 d23464c0ac60 e71846aa07b9
Depended on by
737de0fac722 c7067c7bda56 e57765dee61a 965b950f9b96 c8684d44d549 5d90e0e045e2 3dd6582de060 aa0bfbd11f02 a46d15e72101 67040b158ae2 36e5a9c15a84 8ddebaa7e856 1f78655a0466 155cab142b63 e8e7158bbc73 efd29a0033e8 83940d53664f 89e89836cae7 4ac01a732552
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.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
53 atoms, 5 tree operations, 21 files

Browse the tree at this change

added crates/hub/migrations/0037_flow_variables.sql +13 −0
1 +
-- Variables for OxygenFlow jobs (hub spec §13.2, #102), per level: the
2 +
-- site (scope_id 0), an organisation or a repository. A job gets its
3 +
-- repository's effective set, inner levels overriding outer. Not secrets:
4 +
-- those are mounted by Flow (OxygenFlow spec §9), never put in the
5 +
-- environment.
6 +
CREATE TABLE flow_variables (
7 +
    scope       TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')),
8 +
    scope_id    INTEGER NOT NULL DEFAULT 0,
9 +
    name        TEXT NOT NULL,
10 +
    value       TEXT NOT NULL,
11 +
    updated_ms  INTEGER NOT NULL,
12 +
    PRIMARY KEY (scope, scope_id, name)
13 +
);
added crates/hub/src/levels.rs +70 −0
1 +
//! The three levels settings live at, site, organisation and repository,
2 +
//! and how a repository sees the levels above it. Shared by AI assistants
3 +
//! (§13.4) and Flow variables (§13.2).
4 +
5 +
use crate::db::Pool;
6 +
7 +
/// Which level a setting belongs to.
8 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9 +
pub enum Scope {
10 +
    Site,
11 +
    Org(i64),
12 +
    Repo(i64),
13 +
}
14 +
15 +
impl Scope {
16 +
    pub fn kind(self) -> &'static str {
17 +
        match self {
18 +
            Scope::Site => "site",
19 +
            Scope::Org(_) => "org",
20 +
            Scope::Repo(_) => "repo",
21 +
        }
22 +
    }
23 +
24 +
    pub fn id(self) -> i64 {
25 +
        match self {
26 +
            Scope::Site => 0,
27 +
            Scope::Org(id) | Scope::Repo(id) => id,
28 +
        }
29 +
    }
30 +
}
31 +
32 +
/// A level as a settings page addresses it.
33 +
pub struct At {
34 +
    pub scope: Scope,
35 +
    /// The owner name, so a repository finds its organisation.
36 +
    pub owner: String,
37 +
    /// What the audit log names.
38 +
    pub label: String,
39 +
}
40 +
41 +
impl At {
42 +
    pub fn site() -> At {
43 +
        At { scope: Scope::Site, owner: String::new(), label: "site".into() }
44 +
    }
45 +
46 +
    pub fn org(org: &crate::db::accounts::Organisation) -> At {
47 +
        At { scope: Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() }
48 +
    }
49 +
50 +
    pub fn repo(row: &crate::db::repos::RepoRow) -> At {
51 +
        At { scope: Scope::Repo(row.id), owner: row.owner.clone(), label: format!("{}/{}", row.owner, row.name) }
52 +
    }
53 +
}
54 +
55 +
/// The levels above a scope, outermost first: the site, then the
56 +
/// organisation when the owner is one.
57 +
pub async fn above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> {
58 +
    let mut out = vec![];
59 +
    match scope {
60 +
        Scope::Site => {}
61 +
        Scope::Org(_) => out.push((Scope::Site, "the site".to_string())),
62 +
        Scope::Repo(_) => {
63 +
            out.push((Scope::Site, "the site".to_string()));
64 +
            if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? {
65 +
                out.push((Scope::Org(org.id), format!("the {} organisation", org.name)));
66 +
            }
67 +
        }
68 +
    }
69 +
    Ok(out)
70 +
}
added crates/hub/src/variables.rs +130 −0
1 +
//! Variables for OxygenFlow jobs (hub spec §13.2, oxygenhub#102): plain
2 +
//! `NAME=value` pairs kept per site, organisation and repository. A job
3 +
//! gets its repository's effective set, inner levels overriding outer, as
4 +
//! the claim's `env`; Flow sets them under `FLOW_*` and the pipeline's
5 +
//! own `env`. They are not secrets and are shown in full on the settings
6 +
//! pages; secrets are mounted by Flow (OxygenFlow spec §9).
7 +
8 +
use crate::db::{now_ms, Pool};
9 +
use crate::levels::{self, Scope};
10 +
use sqlx::FromRow;
11 +
use std::collections::BTreeMap;
12 +
13 +
/// How many a level may hold.
14 +
pub const MAX_PER_LEVEL: usize = 100;
15 +
/// The longest value.
16 +
pub const MAX_VALUE: usize = 4096;
17 +
18 +
#[derive(Clone, Debug, FromRow)]
19 +
pub struct Variable {
20 +
    pub scope: String,
21 +
    pub scope_id: i64,
22 +
    pub name: String,
23 +
    pub value: String,
24 +
    pub updated_ms: i64,
25 +
}
26 +
27 +
/// A variable's name: a letter or `_`, then letters, digits and `_`, up
28 +
/// to 64; never `FLOW_*`, which Flow sets itself.
29 +
pub fn name_ok(name: &str) -> bool {
30 +
    let mut chars = name.chars();
31 +
    let first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
32 +
    first && name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.starts_with("FLOW_")
33 +
}
34 +
35 +
/// A value: up to 4096 characters, no control characters but newline and tab.
36 +
pub fn value_ok(value: &str) -> bool {
37 +
    value.len() <= MAX_VALUE && !value.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
38 +
}
39 +
40 +
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Variable>> {
41 +
    sqlx::query_as("SELECT * FROM flow_variables WHERE scope = ? AND scope_id = ? ORDER BY name")
42 +
        .bind(scope.kind())
43 +
        .bind(scope.id())
44 +
        .fetch_all(pool)
45 +
        .await
46 +
}
47 +
48 +
/// Set a variable at a level, new or changed.
49 +
pub async fn set(pool: &Pool, scope: Scope, name: &str, value: &str) -> sqlx::Result<()> {
50 +
    sqlx::query(
51 +
        "INSERT INTO flow_variables (scope, scope_id, name, value, updated_ms) VALUES (?, ?, ?, ?, ?) \
52 +
         ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms",
53 +
    )
54 +
    .bind(scope.kind())
55 +
    .bind(scope.id())
56 +
    .bind(name)
57 +
    .bind(value)
58 +
    .bind(now_ms())
59 +
    .execute(pool)
60 +
    .await?;
61 +
    Ok(())
62 +
}
63 +
64 +
/// Remove a variable from a level; false when it was not there.
65 +
pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result<bool> {
66 +
    let r = sqlx::query("DELETE FROM flow_variables WHERE scope = ? AND scope_id = ? AND name = ?")
67 +
        .bind(scope.kind())
68 +
        .bind(scope.id())
69 +
        .bind(name)
70 +
        .execute(pool)
71 +
        .await?;
72 +
    Ok(r.rows_affected() == 1)
73 +
}
74 +
75 +
/// Forget a level's variables, when the organisation or repository goes.
76 +
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
77 +
    sqlx::query("DELETE FROM flow_variables WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
78 +
    Ok(())
79 +
}
80 +
81 +
/// A variable as a level sees it: where it comes from, and whether the
82 +
/// level's own value hides it.
83 +
#[derive(Clone, Debug)]
84 +
pub struct Seen {
85 +
    pub variable: Variable,
86 +
    /// `the site`, `the acme organisation`, or empty for the level's own.
87 +
    pub from: String,
88 +
    /// An inner level sets the same name, so this value is not the one used.
89 +
    pub overridden: bool,
90 +
}
91 +
92 +
/// Every variable a level sees, outermost first, inherited ones marked
93 +
/// overridden where an inner level sets the same name.
94 +
pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Seen>> {
95 +
    let mut lvls = levels::above(pool, scope, owner).await?;
96 +
    lvls.push((scope, String::new()));
97 +
    let mut out: Vec<Seen> = vec![];
98 +
    for (level, from) in lvls {
99 +
        for v in list(pool, level).await? {
100 +
            for o in out.iter_mut().filter(|o| o.variable.name == v.name) {
101 +
                o.overridden = true;
102 +
            }
103 +
            out.push(Seen { variable: v, from: from.clone(), overridden: false });
104 +
        }
105 +
    }
106 +
    Ok(out)
107 +
}
108 +
109 +
/// The variables a repository's jobs get.
110 +
pub async fn env_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<BTreeMap<String, String>> {
111 +
    Ok(seen(pool, Scope::Repo(repo.id), &repo.owner)
112 +
        .await?
113 +
        .into_iter()
114 +
        .filter(|s| !s.overridden)
115 +
        .map(|s| (s.variable.name, s.variable.value))
116 +
        .collect())
117 +
}
118 +
119 +
#[cfg(test)]
120 +
mod tests {
121 +
    use super::*;
122 +
123 +
    #[test]
124 +
    fn names_and_values_are_checked() {
125 +
        assert!(name_ok("CC") && name_ok("_x") && name_ok("RUST_LOG"));
126 +
        assert!(!name_ok("") && !name_ok("1A") && !name_ok("A-B") && !name_ok("FLOW_REPO") && !name_ok(&"A".repeat(65)));
127 +
        assert!(value_ok("clang") && value_ok("two\nlines\twith tab") && value_ok(""));
128 +
        assert!(!value_ok("a\u{0}b") && !value_ok(&"x".repeat(MAX_VALUE + 1)));
129 +
    }
130 +
}
added crates/hub/src/web/variables.rs +69 −0
1 +
//! The Flow variables panel (hub spec §13.2, oxygenhub#102) shared by
2 +
//! the administration, organisation and repository settings pages: the
3 +
//! level's own variables and the ones it inherits.
4 +
5 +
use crate::auth::Viewer;
6 +
use crate::db::accounts;
7 +
use crate::levels::At;
8 +
use crate::variables::{self, Seen};
9 +
use crate::Shared;
10 +
use serde::Deserialize;
11 +
12 +
#[derive(Deserialize)]
13 +
pub struct VariableForm {
14 +
    #[serde(rename = "_csrf")]
15 +
    pub csrf_token: String,
16 +
    #[serde(default)]
17 +
    action: String,
18 +
    #[serde(default)]
19 +
    name: String,
20 +
    #[serde(default)]
21 +
    value: String,
22 +
}
23 +
24 +
/// Everything the panel shows.
25 +
pub struct Panel {
26 +
    /// The level's own variables.
27 +
    pub own: Vec<Seen>,
28 +
    /// The ones the levels above provide, outermost first.
29 +
    pub inherited: Vec<Seen>,
30 +
}
31 +
32 +
pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> {
33 +
    let (own, inherited): (Vec<_>, Vec<_>) =
34 +
        variables::seen(&app.db, at.scope, &at.owner).await?.into_iter().partition(|v| v.from.is_empty());
35 +
    Ok(Panel { own, inherited })
36 +
}
37 +
38 +
/// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`.
39 +
pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: VariableForm) -> sqlx::Result<Result<String, String>> {
40 +
    let name = f.name.trim();
41 +
    if !variables::name_ok(name) {
42 +
        return Ok(Err(
43 +
            "A variable's name is a letter or _, then letters, digits and _, up to 64, and not FLOW_… (Flow sets those).".into()
44 +
        ));
45 +
    }
46 +
    Ok(Ok(match f.action.as_str() {
47 +
        "set" => {
48 +
            let value = f.value.replace("\r\n", "\n");
49 +
            if !variables::value_ok(&value) {
50 +
                return Ok(Err(format!("A value is up to {} characters of text.", variables::MAX_VALUE)));
51 +
            }
52 +
            let own = variables::list(&app.db, at.scope).await?;
53 +
            if own.len() >= variables::MAX_PER_LEVEL && !own.iter().any(|v| v.name == name) {
54 +
                return Ok(Err(format!("Up to {} variables here.", variables::MAX_PER_LEVEL)));
55 +
            }
56 +
            variables::set(&app.db, at.scope, name, &value).await?;
57 +
            accounts::audit(&app.db, viewer.account(), "flow.variable.set", &at.label, name).await.ok();
58 +
            format!("{name} set.")
59 +
        }
60 +
        "delete" => {
61 +
            if !variables::delete(&app.db, at.scope, name).await? {
62 +
                return Ok(Err(format!("There is no variable {name} here.")));
63 +
            }
64 +
            accounts::audit(&app.db, viewer.account(), "flow.variable.deleted", &at.label, name).await.ok();
65 +
            format!("{name} removed.")
66 +
        }
67 +
        _ => return Ok(Err("Unknown action.".into())),
68 +
    }))
69 +
}
added templates/variables_panel.html +27 −0
1 +
{% macro panel(base, v, csrf, what) %}
2 +
<section class="panel" id="variables">
3 +
  <h2>Variables</h2>
4 +
  <p class="small mute">{{ what }} Jobs get them in their environment under <code>FLOW_*</code> and the pipeline's own <code>env</code>; they are part of each job's receipt. Not for secrets: these are shown in full to everyone who can see this page.</p>
5 +
  {% if !v.inherited.is_empty() %}
6 +
  <h3>Inherited</h3>
7 +
  <div class="table"><table>
8 +
  <tr><th>Name</th><th>Value</th><th>From</th></tr>
9 +
  {% for i in v.inherited %}<tr{% if i.overridden %} class="mute"{% endif %}><td><code>{{ i.variable.name }}</code></td><td class="small"><code>{{ i.variable.value }}</code></td><td class="small">{{ i.from }}{% if i.overridden %} · <span title="A level below sets the same name">overridden</span>{% endif %}</td></tr>{% endfor %}
10 +
  </table></div>
11 +
  {% endif %}
12 +
  {% if v.own.is_empty() %}<p class="mute small">{% if v.inherited.is_empty() %}None.{% else %}None of its own.{% endif %}</p>{% else %}
13 +
  <div class="table"><table>
14 +
  <tr><th>Name</th><th>Value</th><th></th></tr>
15 +
  {% for o in v.own %}<tr><td><code>{{ o.variable.name }}</code></td><td class="small"><code>{{ o.variable.value }}</code></td>
16 +
  <td><form class="tiny" method="post" action="{{ base }}/variables"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="name" value="{{ o.variable.name }}"><button name="action" value="delete" class="danger-btn">remove</button></form></td></tr>{% endfor %}
17 +
  </table></div>{% endif %}
18 +
  <form class="card" method="post" action="{{ base }}/variables">
19 +
    <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="set">
20 +
    <h3>Set a variable</h3>
21 +
    <label for="vname">Name</label><input type="text" id="vname" name="name" placeholder="RUSTFLAGS" required>
22 +
    <label for="vvalue">Value</label><textarea id="vvalue" name="value" rows="2"></textarea>
23 +
    <p class="small mute">An existing name takes the new value; a name set above is overridden here.</p>
24 +
    <button type="submit">SET</button>
25 +
  </form>
26 +
</section>
27 +
{% endmacro %}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Variables for pipelines (#102).** Administration, organisation and repository settings hold variables for OxygenFlow jobs, inner levels overriding outer, each page showing what it inherits; a claim carries the repository's effective set and Flow adds them to the job's environment under `FLOW_*` and the pipeline's own. Not for secrets, which Flow mounts.
8 +
7 9
- **AI assistants, step three: drafted descriptions (#97).** The new-proposal form and an open proposal's editor have a draft-with button per assistant; the answer, written from the changes and diff, lands in the editor for the person to change before saving.
8 10
9 11
- **AI assistants, step two: review requests (#97).** Under Reviewers, the author or a writer asks an assistant the repository may use; the hub sends it the proposal's title, description, changes and diff with the site, organisation and repository prompts, and records the answer as a comment review via the assistant, shown as "bob ↳ agent Claude". Approvals count only people, and an assistant's answer never replaces the asker's own verdict. Also `POST …/proposals/{n}/review-requests` in the API, with `via` on reviews.
…
95 unchanged lines
modified crates/hub/src/assistants.rs +3 −43
…
26 unchanged lines
27 27
/// The most an answer is kept.
28 28
const MAX_ANSWER: usize = 64 * 1024;
29 29
30 -
/// Which level an assistant or a prompt belongs to.
31 -
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32 -
pub enum Scope {
33 -
    Site,
34 -
    Org(i64),
35 -
    Repo(i64),
36 -
}
37 -
38 -
impl Scope {
39 -
    pub fn kind(self) -> &'static str {
40 -
        match self {
41 -
            Scope::Site => "site",
42 -
            Scope::Org(_) => "org",
43 -
            Scope::Repo(_) => "repo",
44 -
        }
45 -
    }
46 -
47 -
    pub fn id(self) -> i64 {
48 -
        match self {
49 -
            Scope::Site => 0,
50 -
            Scope::Org(id) | Scope::Repo(id) => id,
51 -
        }
52 -
    }
53 -
}
30 +
pub use crate::levels::Scope;
54 31
55 32
#[derive(Clone, Debug, FromRow)]
56 33
pub struct Assistant {
…
257 unchanged lines
314 291
}
315 292
316 293
// ----------------------------------------------------- what applies where
317 -
318 -
/// The levels above a scope, outermost first: the site, then the
319 -
/// organisation when the owner is one.
320 -
pub async fn levels_above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> {
321 -
    let mut out = vec![];
322 -
    match scope {
323 -
        Scope::Site => {}
324 -
        Scope::Org(_) => out.push((Scope::Site, "the site".to_string())),
325 -
        Scope::Repo(_) => {
326 -
            out.push((Scope::Site, "the site".to_string()));
327 -
            if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? {
328 -
                out.push((Scope::Org(org.id), format!("the {} organisation", org.name)));
329 -
            }
330 -
        }
331 -
    }
332 -
    Ok(out)
333 -
}
334 294
335 295
/// An assistant seen from a level: where it comes from, and whether it is
336 296
/// on once outer levels have had their say.
…
16 unchanged lines
353 313
/// innermost definition of a name wins, and a name disabled at an outer
354 314
/// level is off inside it whatever the inner level says.
355 315
pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> {
356 -
    let mut levels = levels_above(pool, scope, owner).await?;
316 +
    let mut levels = crate::levels::above(pool, scope, owner).await?;
357 317
    levels.push((scope, String::new()));
358 318
    let mut out: Vec<Available> = vec![];
359 319
    let mut off: Vec<String> = vec![];
…
19 unchanged lines
379 339
/// only the levels that set one.
380 340
pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> {
381 341
    let mut out = vec![];
382 -
    for (level, from) in levels_above(pool, scope, owner).await? {
342 +
    for (level, from) in crate::levels::above(pool, scope, owner).await? {
383 343
        let text = prompt(pool, level).await?;
384 344
        if !text.is_empty() {
385 345
            out.push((from, text));
…
406 unchanged lines
modified crates/hub/src/flow.rs +2 −0
…
503 unchanged lines
504 504
        state: run.state.clone(),
505 505
        bookmark: run.bookmark.clone(),
506 506
        run_number: run.number,
507 +
        // The site's, the owner's and the repository's variables (#102).
508 +
        env: crate::variables::env_for_repo(&app.db, &row).await?,
507 509
    };
508 510
    let mut claim = claim;
509 511
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
…
541 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
12 unchanged lines
13 13
pub mod flow;
14 14
pub mod hooks;
15 15
pub mod keys;
16 +
pub mod levels;
16 17
pub mod live;
17 18
pub mod mail;
18 19
pub mod maintenance;
…
2 unchanged lines
21 22
pub mod protocol;
22 23
pub mod render;
23 24
pub mod repos;
25 +
pub mod variables;
24 26
pub mod web;
25 27
26 28
use crate::config::Config;
…
103 unchanged lines
modified crates/hub/src/web/admin.rs +19 −2
…
29 unchanged lines
30 30
    my_email: String,
31 31
    /// The site's AI assistants and prompt (#97).
32 32
    assistants: super::assistants::Panel,
33 +
    /// The site's Flow variables (#102).
34 +
    variables: super::variables::Panel,
33 35
}
34 36
35 37
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
…
17 unchanged lines
53 55
        None => String::new(),
54 56
    };
55 57
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
58 +
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
56 59
    Ok(Page(AdminPage {
57 60
        assistants,
61 +
        variables,
58 62
        shell,
59 63
        accounts: accounts_list,
60 64
        orgs,
…
305 unchanged lines
366 370
    storage_page(&app, shell, Some(summary), error).await
367 371
}
368 372
369 -
fn site_assistants() -> super::assistants::Where {
370 -
    super::assistants::Where { scope: crate::assistants::Scope::Site, owner: String::new(), label: "site".into() }
373 +
fn site_assistants() -> crate::levels::At {
374 +
    crate::levels::At::site()
371 375
}
372 376
373 377
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
…
8 unchanged lines
382 386
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
383 387
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
384 388
}
389 +
390 +
/// `POST /admin/variables`: the site's Flow variables (#102).
391 +
pub async fn variables(
392 +
    State(app): State<Shared>,
393 +
    viewer: Viewer,
394 +
    Form(f): Form<super::variables::VariableForm>,
395 +
) -> Result<Page<AdminPage>, Error> {
396 +
    let shell = Shell::new(&app, "/admin", &viewer);
397 +
    require_admin(&viewer, &shell)?;
398 +
    csrf(&viewer, &f.csrf_token, &shell)?;
399 +
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
400 +
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
401 +
}
modified crates/hub/src/web/assistants.rs +4 −12
…
1 unchanged lines
2 2
//! administration, organisation and repository settings pages: the
3 3
//! level's own assistants, the ones it inherits, and its prompt.
4 4
5 -
use crate::assistants::{self, Assistant, Available, Fields, Scope, APIS, DEFAULT_BUDGET};
5 +
use crate::assistants::{self, Assistant, Available, Fields, APIS, DEFAULT_BUDGET};
6 6
use crate::auth::Viewer;
7 7
use crate::db::accounts;
8 +
use crate::levels::At;
8 9
use crate::Shared;
9 10
use serde::Deserialize;
10 11
11 -
/// Whose panel.
12 -
pub struct Where {
13 -
    pub scope: Scope,
14 -
    /// The owner name, so a repository finds its organisation.
15 -
    pub owner: String,
16 -
    /// What the audit log names.
17 -
    pub label: String,
18 -
}
19 -
20 12
#[derive(Deserialize)]
21 13
pub struct AssistantForm {
22 14
    #[serde(rename = "_csrf")]
…
36 unchanged lines
59 51
    pub default_budget: i64,
60 52
}
61 53
62 -
pub async fn panel(app: &Shared, at: &Where) -> sqlx::Result<Panel> {
54 +
pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> {
63 55
    let (own, inherited): (Vec<_>, Vec<_>) =
64 56
        assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty());
65 57
    Ok(Panel {
…
50 unchanged lines
116 108
}
117 109
118 110
/// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`.
119 -
pub async fn apply(app: &Shared, viewer: &Viewer, at: &Where, f: AssistantForm) -> sqlx::Result<Result<String, String>> {
111 +
pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result<Result<String, String>> {
120 112
    let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope);
121 113
    let actor = viewer.account();
122 114
    Ok(Ok(match (f.action.as_str(), f.id) {
…
64 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
30 unchanged lines
31 31
mod session;
32 32
pub(crate) mod settings;
33 33
mod statics;
34 +
mod variables;
34 35
mod webhooks;
35 36
36 37
use crate::auth::{self, Access, Role, Viewer};
…
47 unchanged lines
84 85
        .route("/orgs/{name}/settings/teams", post(orgs::team))
85 86
        .route("/orgs/{name}/settings/hooks", post(orgs::hooks))
86 87
        .route("/orgs/{name}/settings/assistants", post(orgs::assistants))
88 +
        .route("/orgs/{name}/settings/variables", post(orgs::variables))
87 89
        .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token))
88 90
        .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove))
89 91
        .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries))
…
19 unchanged lines
109 111
        .route("/admin/mail", post(admin::set_mail))
110 112
        .route("/admin/mail/test", post(admin::test_mail))
111 113
        .route("/admin/assistants", post(admin::assistants))
114 +
        .route("/admin/variables", post(admin::variables))
112 115
        .route("/admin/runners/{id}/remove", post(runners::remove))
113 116
        .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize))
114 117
        .route("/{owner}", get(home::owner))
…
54 unchanged lines
169 172
        .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events))
170 173
        .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks))
171 174
        .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants))
175 +
        .route("/{owner}/{name}/settings/variables", post(repo_settings::variables))
172 176
        .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries))
173 177
        .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver))
174 178
        .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks))
…
460 unchanged lines
modified crates/hub/src/web/orgs.rs +23 −3
…
71 unchanged lines
72 72
    runner_token: Option<String>,
73 73
    /// The organisation's AI assistants and prompt (#97).
74 74
    assistants: super::assistants::Panel,
75 +
    /// The organisation's Flow variables (#102).
76 +
    variables: super::variables::Panel,
75 77
}
76 78
77 79
async fn org_page(
…
22 unchanged lines
100 102
        runners.push((r, serves, own));
101 103
    }
102 104
    let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
103 -
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants }))
105 +
    let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
106 +
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants, variables }))
104 107
}
105 108
106 109
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
…
256 unchanged lines
363 366
    org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
364 367
}
365 368
366 -
fn org_assistants(org: &accounts::Organisation) -> super::assistants::Where {
367 -
    super::assistants::Where { scope: crate::assistants::Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() }
369 +
fn org_assistants(org: &accounts::Organisation) -> crate::levels::At {
370 +
    crate::levels::At::org(org)
368 371
}
369 372
370 373
/// `POST /orgs/{name}/settings/assistants`: the organisation's assistants
…
12 unchanged lines
383 386
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
384 387
    }
385 388
}
389 +
390 +
/// `POST /orgs/{name}/settings/variables`: the organisation's Flow
391 +
/// variables (#102).
392 +
pub async fn variables(
393 +
    State(app): State<Shared>,
394 +
    viewer: Viewer,
395 +
    Path(name): Path<String>,
396 +
    Form(f): Form<super::variables::VariableForm>,
397 +
) -> Result<Page<OrgSettingsPage>, Error> {
398 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
399 +
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
400 +
    csrf(&viewer, &f.csrf_token, &shell)?;
401 +
    match super::variables::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
402 +
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
403 +
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
404 +
    }
405 +
}
modified crates/hub/src/web/repo_settings.rs +25 −7
…
101 unchanged lines
102 102
    runner_token: Option<String>,
103 103
    /// The repository's AI assistants and prompt (#97).
104 104
    assistants: super::assistants::Panel,
105 +
    /// The repository's Flow variables (#102).
106 +
    variables: super::variables::Panel,
105 107
}
106 108
107 109
async fn settings_page(
…
34 unchanged lines
142 144
        })
143 145
        .collect();
144 146
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
147 +
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
145 148
    Ok(Page(RepoSettingsPage {
146 149
        assistants,
150 +
        variables,
147 151
        runners,
148 152
        runner_token: None,
149 153
        shell,
…
150 unchanged lines
300 304
    let _guard = app.repos.write_guard(&repo).await;
301 305
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
302 306
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
303 -
    crate::assistants::delete_scope(&app.db, crate::assistants::Scope::Repo(row.id)).await.ok();
307 +
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
308 +
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
304 309
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
305 310
    Ok(Redirect::to(&format!("/{owner}")).into_response())
306 311
}
…
216 unchanged lines
523 528
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
524 529
}
525 530
526 -
fn repo_assistants(row: &db::repos::RepoRow) -> super::assistants::Where {
527 -
    super::assistants::Where {
528 -
        scope: crate::assistants::Scope::Repo(row.id),
529 -
        owner: row.owner.clone(),
530 -
        label: format!("{}/{}", row.owner, row.name),
531 -
    }
531 +
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
532 +
    crate::levels::At::repo(row)
532 533
}
533 534
534 535
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
…
8 unchanged lines
543 544
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
544 545
    csrf(&viewer, &f.csrf_token, &shell)?;
545 546
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
547 +
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
548 +
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
549 +
    }
550 +
}
551 +
552 +
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
553 +
/// variables (#102), for its admins.
554 +
pub async fn variables(
555 +
    State(app): State<Shared>,
556 +
    viewer: Viewer,
557 +
    Path((owner, name)): Path<(String, String)>,
558 +
    Form(f): Form<super::variables::VariableForm>,
559 +
) -> Result<Page<RepoSettingsPage>, Error> {
560 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
561 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
562 +
    csrf(&viewer, &f.csrf_token, &shell)?;
563 +
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
546 564
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
547 565
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
548 566
    }
…
1 unchanged lines
modified crates/hub/tests/accounts.rs +1 −1
…
605 unchanged lines
606 606
        &format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"),
607 607
    )
608 608
    .await;
609 -
    assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("secret"), "{}", r.body);
609 +
    assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("hub:secret"), "{}", r.body);
610 610
    let r = post(
611 611
        app,
612 612
        "/admin/mail",
…
180 unchanged lines
modified crates/hub/tests/flow.rs +86 −0
…
1717 unchanged lines
1718 1718
    assert_eq!(names, ["nightly"]);
1719 1719
    assert_eq!(runs[0].bookmark, "main", "on the default bookmark");
1720 1720
}
1721 +
1722 +
#[tokio::test(flavor = "multi_thread")]
1723 +
async fn variables_from_three_levels_reach_a_job_inner_overriding_outer() {
1724 +
    use oxygenhub::levels::Scope;
1725 +
    use oxygenhub::variables;
1726 +
    let h = hub().await;
1727 +
    let app = &h.app;
1728 +
    // Andy, the site's administrator, owns the acme organisation.
1729 +
    let res = oxygenhub::router(app.clone())
1730 +
        .oneshot(
1731 +
            Request::post("/register")
1732 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1733 +
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery"))
1734 +
                .unwrap(),
1735 +
        )
1736 +
        .await
1737 +
        .unwrap();
1738 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
1739 +
    let andy = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
1740 +
    db::accounts::create_org(&app.db, "acme", "Acme", andy.id).await.unwrap();
1741 +
    create(app, "acme", "app").await;
1742 +
    let page = |path: &str, form: Option<String>| {
1743 +
        let mut req =
1744 +
            Request::builder().method(if form.is_some() { "POST" } else { "GET" }).uri(path).header(header::COOKIE, cookie.clone());
1745 +
        if form.is_some() {
1746 +
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
1747 +
        }
1748 +
        let req = req.body(Body::from(form.unwrap_or_default())).unwrap();
1749 +
        let app = app.clone();
1750 +
        async move {
1751 +
            let res = oxygenhub::router(app).oneshot(req).await.unwrap();
1752 +
            let status = res.status();
1753 +
            let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
1754 +
            (status, body)
1755 +
        }
1756 +
    };
1757 +
    let csrf_of = |page: &str| {
1758 +
        let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
1759 +
        page[i..].split('"').next().unwrap().to_string()
1760 +
    };
1761 +
1762 +
    // The site sets CC and LANG, the organisation its own CC, the
1763 +
    // repository RUST_LOG from its settings page.
1764 +
    variables::set(&app.db, Scope::Site, "CC", "gcc").await.unwrap();
1765 +
    variables::set(&app.db, Scope::Site, "LANG", "C").await.unwrap();
1766 +
    let org = db::accounts::org_by_name(&app.db, "acme").await.unwrap().unwrap();
1767 +
    variables::set(&app.db, Scope::Org(org.id), "CC", "clang").await.unwrap();
1768 +
    let (st, settings) = page("/acme/app/settings", None).await;
1769 +
    assert_eq!(st, StatusCode::OK, "{settings}");
1770 +
    assert!(settings.contains("Inherited") && settings.contains("the acme organisation") && settings.contains("overridden"), "{settings}");
1771 +
    let csrf = csrf_of(&settings);
1772 +
    let (_, body) = page("/acme/app/settings/variables", Some(format!("_csrf={csrf}&action=set&name=RUST_LOG&value=debug"))).await;
1773 +
    assert!(body.contains("RUST_LOG set.") && body.contains("<code>debug</code>"), "{body}");
1774 +
    let (_, body) = page("/acme/app/settings/variables", Some(format!("_csrf={csrf}&action=set&name=FLOW_REPO&value=x"))).await;
1775 +
    assert!(body.contains("not FLOW_"), "{body}");
1776 +
    let repo = db::repos::get(&app.db, "acme", "app").await.unwrap().unwrap();
1777 +
    db::repos::set_flow(&app.db, repo.id, "on").await.unwrap();
1778 +
    let env = variables::env_for_repo(&app.db, &repo).await.unwrap();
1779 +
    assert_eq!(
1780 +
        env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::<Vec<_>>(),
1781 +
        [("CC", "clang"), ("LANG", "C"), ("RUST_LOG", "debug")],
1782 +
        "the organisation's CC wins over the site's"
1783 +
    );
1784 +
1785 +
    // A claim carries them.
1786 +
    let url = format!("{}/acme/app", h.base);
1787 +
    let work = tempfile::tempdir().unwrap();
1788 +
    let c = client(&url, work.path());
1789 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
1790 +
    write(work.path(), "test.sh", "echo ok\n");
1791 +
    c.snapshot("test").unwrap();
1792 +
    c.commit("Add a pipeline", &[]).unwrap();
1793 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1794 +
    let runs = runs_of(app, "acme", "app").await;
1795 +
    assert_eq!(runs.len(), 1, "{runs:?}");
1796 +
    let got = claim(app).await.unwrap_or_else(|| panic!("a job: {:?}", runs[0]));
1797 +
    assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "LANG": "C", "RUST_LOG": "debug" }), "{got}");
1798 +
1799 +
    // Removing a site variable from the admin page takes effect at the next claim.
1800 +
    let (_, admin) = page("/admin", None).await;
1801 +
    assert!(admin.contains("Variables") && admin.contains("<code>gcc</code>"), "{admin}");
1802 +
    let (_, body) = page("/admin/variables", Some(format!("_csrf={}&action=delete&name=LANG", csrf_of(&admin)))).await;
1803 +
    assert!(body.contains("LANG removed."), "{body}");
1804 +
    let got = claim(app).await.expect("another job");
1805 +
    assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "RUST_LOG": "debug" }), "{got}");
1806 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −0
…
495 unchanged lines
496 496
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
497 497
498 498
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
499 +
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
499 500
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
500 501
  - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB).
501 502
  - The hub refuses names the job does not declare. It keeps each artifact as a blob of the run's repository, records its path, size and BLAKE3 in `flow_artifacts`, and answers with the blob's ObjectRef.
…
196 unchanged lines
modified templates/admin.html +2 −0
1 1
{% extends "base.html" %}
2 2
{% import "assistants_panel.html" as ai %}
3 +
{% import "variables_panel.html" as va %}
3 4
{% block title %}Administration{% endblock %}
4 5
{% block content %}
5 6
<div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div>
…
50 unchanged lines
56 57
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p>
57 58
</aside>
58 59
</div>
60 +
{% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %}
59 61
{% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %}
60 62
{% endblock %}
modified templates/org_settings.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% import "webhooks_panel.html" as w %}
3 3
{% import "assistants_panel.html" as ai %}
4 +
{% import "variables_panel.html" as va %}
4 5
{% block title %}{{ org.name }} settings{% endblock %}
5 6
{% block content %}
6 7
<div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div>
…
3 unchanged lines
10 11
  <a href="#logo">Logo</a>
11 12
  <a href="#members">Members</a>
12 13
  <a href="#webhooks">Webhooks</a>
14 +
  <a href="#variables">Variables</a>
13 15
  <a href="#assistants">AI assistants</a>
14 16
  <a href="#teams">Teams</a>
15 17
</nav>
…
59 unchanged lines
75 77
  <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form>
76 78
</section>
77 79
{% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %}
80 +
{% call va::panel("/orgs/" ~ org.name ~ "/settings", variables, shell.csrf, "Jobs of every repository of the organisation get these, beside the site's and each repository's own.") %}
78 81
{% call ai::panel("/orgs/" ~ org.name ~ "/settings", assistants, shell.csrf, "Every repository of the organisation may ask these, beside the site's and its own.") %}
79 82
</div>
80 83
</div>
…
1 unchanged lines
modified templates/repo_settings.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% import "webhooks_panel.html" as w %}
3 3
{% import "assistants_panel.html" as ai %}
4 +
{% import "variables_panel.html" as va %}
4 5
{% import "repo_tabs.html" as m %}
5 6
{% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %}
6 7
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
…
10 unchanged lines
17 18
  <a href="#checks">Required checks</a>
18 19
  <a href="#flow">OxygenFlow</a>
19 20
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
21 +
  <a href="#variables">Variables</a>
20 22
  <a href="#assistants">AI assistants</a>
21 23
  <a href="#collaborators">Collaborators</a>
22 24
  <a href="#danger">Delete</a>{% endif %}
…
68 unchanged lines
91 93
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
92 94
</section>
93 95
{% 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.") %}
96 +
{% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %}
94 97
{% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %}
95 98
<section class="panel" id="collaborators">
96 99
  <h2>Collaborators</h2>
…
30 unchanged lines