OXYGENHUB

Coverage / crates/hub/src/web/repo_settings.rs 79.2%

1060 of 1339 lines run, 110 of 408 functions · run #545 on main · run missed

1
//! Creating repositories and their settings pages (hub spec §6.3).
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{roles, Access, Role, Viewer};
6
use crate::db::{self, accounts};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::http::Uri;
12
use axum::response::{IntoResponse, Redirect, Response};
13
use hub_api::Visibility;
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "repo_new.html")]
18
pub struct NewRepoPage {
19
    shell: Shell,
20
    owners: Vec<String>,
21
    error: Option<String>,
22
    /// The open boards of each owner the viewer may change, for "add it to
23
    /// an existing board" (#360): (owner, number, title).
24
    boards: Vec<(String, i64, String)>,
25
    /// Owners that are organisations: a board is on by default for them.
26
    orgs: Vec<String>,
27
    licences: &'static [(&'static str, &'static str)],
28
    ignores: &'static [&'static str],
29
    base_url: String,
30
    /// Whether the viewer may put a repository on the site's runners (#371).
31
    admin: bool,
32
    /// Owners with runners of their own, for "on its owner's runners".
33
    runner_owners: Vec<String>,
34
    /// Each owner's default bookmark and where it comes from (#371).
35
    bookmarks: Vec<(String, String, String)>,
36
}
37
38
/// The New repository page's choices for `me` (#360).
3951×
async fn new_page(app: &Shared, shell: Shell, me: &accounts::Account, error: Option<String>) -> NewRepoPage {
4051×
    let owners = owners_for(app, me).await;
4151×
    let mut boards = Vec::new();
4251×
    let mut orgs = Vec::new();
4351×
    let mut runner_owners = Vec::new();
4451×
    let mut bookmarks = Vec::new();
4562×
    for o in &owners {
4662×
        let (b, from) = crate::repo_setup::default_bookmark_for(app, o).await;
4762×
        bookmarks.push((o.clone(), b, from));
4862×
        if crate::repo_setup::owner_has_runner(app, o).await.unwrap_or(false) {
490×
            runner_owners.push(o.clone());
5062×
        }
5162×
        if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() {
5211×
            orgs.push(o.clone());
5351×
        }
5462×
        let may = crate::web::projects::rights_of(app, Some(me), o).await.ok().flatten().is_some_and(|r| r.edit);
5562×
        if may {
5662×
            for p in db::projects::of_owner(&app.db, o).await.unwrap_or_default().into_iter().filter(|p| !p.closed) {
573×
                boards.push((o.clone(), p.number, p.title));
583×
            }
590×
        }
60
    }
6151×
    NewRepoPage {
6251×
        shell,
6351×
        owners,
6451×
        error,
6551×
        boards,
6651×
        orgs,
6751×
        licences: crate::repo_setup::LICENCES,
6851×
        ignores: crate::repo_setup::IGNORES,
6951×
        base_url: app.config.base_url().trim_end_matches('/').to_string(),
7051×
        admin: me.is_admin,
7151×
        runner_owners,
7251×
        bookmarks,
7351×
    }
7451×
}
75
7654×
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
7754×
    let mut owners = vec![me.username.clone()];
7854×
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
7954×
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
800×
    }
8154×
    owners
8254×
}
83
8425×
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
8525×
    let shell = Shell::new(&app, "/new", &viewer);
8625×
    let me = require(&viewer, &shell)?;
8725×
    Ok(Page(new_page(&app, shell, &me.account, None).await))
8825×
}
89
90
#[derive(Deserialize)]
91
pub struct NameCheck {
92
    #[serde(default)]
93
    owner: String,
94
    #[serde(default)]
95
    name: String,
96
}
97
98
/// `GET /new/check?owner=&name=` (#360): the page's live check that a name
99
/// is free and valid, answering `{"ok", "message", "address"}`.
1003×
pub async fn check_name(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NameCheck>) -> Response {
1013×
    let Some(me) = viewer.account() else {
1020×
        return (axum::http::StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"ok": false, "message": "Sign in first."})))
1030×
            .into_response();
104
    };
1053×
    if q.name.trim().is_empty() {
1060×
        return axum::Json(serde_json::json!({"ok": false, "message": ""})).into_response();
1073×
    }
1083×
    match crate::repo_setup::check_name(&app, me, &q.owner, &q.name).await {
1091×
        Ok(n) => axum::Json(serde_json::json!({
1101×
            "ok": true,
1111×
            "message": format!("{n} is free."),
1121×
            "address": format!("{}{}", app.config.base_url().trim_end_matches('/'), n.url()),
1131×
        }))
1141×
        .into_response(),
1152×
        Err(m) => axum::Json(serde_json::json!({"ok": false, "message": m})).into_response(),
116
    }
1173×
}
118
119
#[derive(Deserialize)]
120
pub struct NewRepoForm {
121
    #[serde(rename = "_csrf")]
122
    csrf_token: String,
123
    owner: String,
124
    name: String,
125
    #[serde(default)]
126
    description: String,
127
    #[serde(default = "default_visibility")]
128
    visibility: String,
129
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
130
    #[serde(default)]
131
    storage: String,
132
    #[serde(flatten)]
133
    bucket: BucketForm,
134
    /// What it starts with (#360): a README, a licence, an `.oxyignore`.
135
    #[serde(default)]
136
    readme: Option<String>,
137
    #[serde(default)]
138
    licence: String,
139
    #[serde(default)]
140
    ignore: String,
141
    /// A board with it: `new` (titled `board_title`), a board number, or none.
142
    #[serde(default)]
143
    board: String,
144
    #[serde(default)]
145
    board_title: String,
146
    /// The existing board's number, with `board=existing`.
147
    #[serde(default)]
148
    board_n: String,
149
    /// OxygenFlow (#371): `on`, `own`, or the site default.
150
    #[serde(default)]
151
    flow: String,
152
    /// Start the wiki with a Home page (#371).
153
    #[serde(default)]
154
    wiki: Option<String>,
155
    /// Its default bookmark; empty, the owner's or the site's default (#371).
156
    #[serde(default)]
157
    default_bookmark: String,
158
}
159
160
/// A bucket and the credentials for it, as the New repository form and
161
/// Settings → Storage give them; only the kind the URL names is read.
162
#[derive(Deserialize, Default)]
163
pub struct BucketForm {
164
    #[serde(default)]
165
    store_url: String,
166
    #[serde(default)]
167
    access_key_id: String,
168
    #[serde(default)]
169
    secret_access_key: String,
170
    #[serde(default)]
171
    service_account_key: String,
172
    #[serde(default)]
173
    tenant_id: String,
174
    #[serde(default)]
175
    client_id: String,
176
    #[serde(default)]
177
    client_secret: String,
178
}
179
180
impl BucketForm {
1811×
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
1821×
        crate::storage::credentials_from(
1831×
            scheme,
1841×
            &self.access_key_id,
1851×
            &self.secret_access_key,
1861×
            &self.service_account_key,
1871×
            &self.tenant_id,
1881×
            &self.client_id,
1891×
            &self.client_secret,
190
        )
1911×
    }
192
}
193
19418×
fn default_visibility() -> String {
19518×
    "public".into()
19618×
}
197
19826×
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
19926×
    let shell = Shell::new(&app, "/new", &viewer);
20026×
    let me = require(&viewer, &shell)?;
20126×
    csrf(&viewer, &f.csrf_token, &shell)?;
20226×
    let page = new_page(&app, shell.clone(), &me.account, None).await;
20326×
    let fail = |msg: &str| Page(NewRepoPage { error: Some(msg.into()), ..clone_page(&page) }).into_response();
20426×
    let name = match crate::repo_setup::check_name(&app, &me.account, &f.owner, &f.name).await {
20524×
        Ok(n) => n,
2062×
        Err(m) => return Ok(fail(&m)),
207
    };
20824×
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
209
    // Its board and its starting files are checked before anything is made (#360).
21024×
    let choice = if f.board == "existing" { f.board_n.as_str() } else { f.board.as_str() };
21124×
    let board = match crate::repo_setup::Board::parse(choice, &f.board_title, &name.name) {
21224×
        Ok(b) => b,
2130×
        Err(m) => return Ok(fail(&m)),
214
    };
21524×
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
2160×
        return Ok(fail(&m));
21724×
    }
21824×
    let flow = match crate::repo_setup::Flow::parse(&f.flow) {
21924×
        Ok(flow) => flow,
2200×
        Err(m) => return Ok(fail(&m)),
221
    };
22224×
    if let Err(m) = crate::repo_setup::check_flow(&app, &me.account, &name.owner, flow).await {
2230×
        return Ok(fail(&m));
22424×
    }
22524×
    let wiki = f.wiki.is_some();
22624×
    if !f.default_bookmark.trim().is_empty() {
2270×
        if let Err(m) = crate::repo_setup::check_bookmark(f.default_bookmark.trim()) {
2280×
            return Ok(fail(&m));
2290×
        }
23024×
    }
23124×
    let start =
23224×
        crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() };
23324×
    let year = time::OffsetDateTime::now_utc().year();
23424×
    let holder = if me.account.display_name.trim().is_empty() { me.account.username.clone() } else { me.account.display_name.clone() };
23524×
    let files = match start.files(&name, &f.description, &holder, year) {
23624×
        Ok(files) => files,
2370×
        Err(m) => return Ok(fail(&m)),
238
    };
23924×
    if !files.is_empty() && f.storage == "bucket" {
2400×
        return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it."));
24124×
    }
24224×
    if !files.is_empty() || wiki {
2431×
        if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await {
2441×
            return Ok(fail(&format!("The hub cannot write the starting files or the wiki for you yet. {m}")));
2450×
        }
24623×
    }
247
    // An organisation's storage policy (oxygenhub#96).
24823×
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
2495×
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2505×
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
2515×
        if let Err(e) = policy.permits(&org.name, store) {
2522×
            return Ok(fail(&format!("{e}.")));
2533×
        }
25418×
    }
25521×
    if f.storage == "bucket" {
256
        // Kept in the owner's bucket (oxygenhub#96): checked before
257
        // anything is made, and nothing is left behind when it fails.
2586×
        let url = f.bucket.store_url.trim();
2596×
        let parsed = match crate::storage::check_url(url) {
2601×
            Ok(p) => p,
2615×
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
262
        };
2631×
        let credentials = match f.bucket.credentials(&parsed.scheme) {
2640×
            Ok(c) => c,
2651×
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
266
        };
2670×
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
2680×
            .await
2690×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
2700×
        let row =
2710×
            crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2720×
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
2730×
            db::repos::delete(&app.db, row.id).await.ok();
2740×
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
2750×
        }
2760×
        accounts::audit(
2770×
            &app.db,
2780×
            Some(&me.account),
2790×
            "repo.created",
2800×
            &name.to_string(),
2810×
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
2820×
        )
2830×
        .await
2840×
        .ok();
2850×
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
2860×
            .await
2870×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
2880×
        crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2890×
        if wiki {
2900×
            if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
2910×
                tracing::warn!("wiki for {name}: {e}");
2920×
            }
2930×
        }
2940×
        return Ok(Redirect::to(&name.url()).into_response());
29515×
    }
29615×
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
29715×
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
29815×
        .await
29915×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
30015×
    let row =
30115×
        crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
30215×
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
30315×
    crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
30415×
        .await
30515×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
30615×
    crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
30715×
    if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await {
3080×
        tracing::warn!("starting files for {name}: {e}");
30915×
    }
31015×
    if wiki {
3110×
        if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
3120×
            tracing::warn!("wiki for {name}: {e}");
3130×
        }
31415×
    }
31515×
    Ok(Redirect::to(&name.url()).into_response())
31626×
}
317
31811×
fn clone_page(p: &NewRepoPage) -> NewRepoPage {
31911×
    NewRepoPage {
32011×
        shell: p.shell.clone(),
32111×
        owners: p.owners.clone(),
32211×
        error: p.error.clone(),
32311×
        boards: p.boards.clone(),
32411×
        orgs: p.orgs.clone(),
32511×
        licences: p.licences,
32611×
        ignores: p.ignores,
32711×
        base_url: p.base_url.clone(),
32811×
        admin: p.admin,
32911×
        runner_owners: p.runner_owners.clone(),
33011×
        bookmarks: p.bookmarks.clone(),
33111×
    }
33211×
}
333
334
/// One page of a repository's settings (#278), at
335
/// `/{owner}/{name}/settings/{slug}`; `/settings` is General.
336
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337
pub enum Section {
338
    General,
339
    Collaborators,
340
    Landing,
341
    Checks,
342
    PushProtection,
343
    Pipelines,
344
    Runs,
345
    Gates,
346
    Runners,
347
    Variables,
348
    Secrets,
349
    Coverage,
350
    Webhooks,
351
    Assistants,
352
    GitMirror,
353
    Diffusion,
354
    Storage,
355
    Backup,
356
    Advanced,
357
}
358
359
/// The sidebar: each group's heading and its pages, in order.
360
const GROUPS: &[(&str, &[Section])] = &[
361
    ("General", &[Section::General, Section::Collaborators]),
362
    ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]),
363
    (
364
        "Flow",
365
        &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage],
366
    ),
367
    ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]),
368
    ("Data", &[Section::Storage, Section::Backup]),
369
    ("Danger zone", &[Section::Advanced]),
370
];
371
372
impl Section {
3735908×
    pub fn slug(self) -> &'static str {
3745908×
        match self {
375405×
            Section::General => "general",
376372×
            Section::Collaborators => "collaborators",
377388×
            Section::Landing => "landing",
378385×
            Section::Checks => "checks",
379392×
            Section::PushProtection => "push-protection",
380366×
            Section::Pipelines => "flow",
381343×
            Section::Runs => "runs",
382331×
            Section::Gates => "gates",
383302×
            Section::Runners => "runners",
384299×
            Section::Variables => "variables",
385291×
            Section::Secrets => "secrets",
386313×
            Section::Coverage => "coverage",
387289×
            Section::Webhooks => "webhooks",
388274×
            Section::Assistants => "assistants",
389296×
            Section::GitMirror => "git-mirror",
390291×
            Section::Diffusion => "diffusion",
39121×
            Section::Storage => "storage",
392289×
            Section::Backup => "backup",
393261×
            Section::Advanced => "advanced",
394
        }
3955908×
    }
396
3972421×
    pub fn title(self) -> &'static str {
3982421×
        match self {
399133×
            Section::General => "General",
400126×
            Section::Collaborators => "Collaborators",
401136×
            Section::Landing => "Landing rules",
402138×
            Section::Checks => "Required checks",
403153×
            Section::PushProtection => "Push protection",
404152×
            Section::Pipelines => "Pipelines",
405141×
            Section::Runs => "Runs & priority",
406136×
            Section::Gates => "Gates & agents",
407126×
            Section::Runners => "Runners",
408125×
            Section::Variables => "Variables",
409123×
            Section::Secrets => "Secrets",
410136×
            Section::Coverage => "Coverage",
411129×
            Section::Webhooks => "Webhooks",
412124×
            Section::Assistants => "AI assistants",
413137×
            Section::GitMirror => "Git mirror",
414136×
            Section::Diffusion => "Diffusion",
4150×
            Section::Storage => "Storage",
416139×
            Section::Backup => "Backup",
417131×
            Section::Advanced => "Advanced",
418
        }
4192421×
    }
420
421133×
    pub fn parse(slug: &str) -> Option<Section> {
4221232×
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
423133×
    }
424
425
    /// Whether only the repository's admins use it; its maintainers use
426
    /// the rest. The roles are those of each page's forms.
4272668×
    fn admin_only(self) -> bool {
4281552×
        matches!(
4292668×
            self,
430
            Section::Collaborators
431
                | Section::Runners
432
                | Section::Variables
433
                | Section::Secrets
434
                | Section::Webhooks
435
                | Section::Assistants
436
                | Section::Storage
437
                | Section::Advanced
438
        )
4392668×
    }
440
441
    /// Whether this viewer has the page: Storage only for a repository kept
442
    /// in a bucket.
4432668×
    fn shown(self, role: Role, in_bucket: bool) -> bool {
4442668×
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
4452668×
    }
446
}
447
448
/// The sidebar a viewer sees: the groups with at least one page for them.
449133×
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
450133×
    GROUPS
451133×
        .iter()
4522527×
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
453798×
        .filter(|(_, pages)| !pages.is_empty())
454133×
        .collect()
455133×
}
456
457
/// Back to the page that holds a settings form, with what happened (#278).
45869×
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
45969×
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
46069×
    if let Some(n) = notice {
46151×
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
46251×
    } else if let Some(e) = error {
46318×
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
46418×
    }
46569×
    Ok(Redirect::to(&to).into_response())
46669×
}
467
468
#[derive(Template)]
469
#[template(path = "repo_settings.html")]
470
pub struct RepoSettingsPage {
471
    /// The page shown, and the sidebar's groups of pages.
472
    section: Section,
473
    nav: Vec<(&'static str, Vec<Section>)>,
474
    shell: Shell,
475
    repo: RepoShell,
476
    row: db::repos::RepoRow,
477
    collaborators: Vec<accounts::Collaborator>,
478
    teams: Vec<accounts::Team>,
479
    role: Role,
480
    requirements: db::proposals::Requirements,
481
    hooks: Vec<super::webhooks::HookRow>,
482
    required_checks: String,
483
    /// Whether hub.toml alone would run Flow here, and whether it runs.
484
    flow_default: bool,
485
    /// hub.toml's `[diffusion] follow` (#148).
486
    diffusion_site: String,
487
    flow_enabled: bool,
488
    site_admin: bool,
489
    /// Whether the repository or its owner has a runner of its own (#225).
490
    own_runner: bool,
491
    notice: Option<String>,
492
    error: Option<String>,
493
    /// Runners that may take this repository's jobs: (runner, what it
494
    /// serves, whether it is this repository's own).
495
    runners: Vec<(db::runners::Runner, String, bool)>,
496
    /// A registration token just made, shown once.
497
    runner_token: Option<String>,
498
    /// The repository's AI assistants and prompt (#97).
499
    assistants: super::assistants::Panel,
500
    /// The repository's Flow variables (#102).
501
    variables: super::variables::Panel,
502
    /// The repository's Flow secrets (#107).
503
    secrets: super::secrets::Panel,
504
    /// Where its data lives, when in a bucket (oxygenhub#96).
505
    storage: Option<crate::storage::Storage>,
506
    /// The coverage check's rules (#113).
507
    coverage: crate::coverage::Rules,
508
    /// Its owner's backup, when one is set up (docs/backups.md §5).
509
    backup_owner: Option<crate::backups::Setting>,
510
    /// Push protection (#268): the findings of recent refused pushes not
511
    /// yet allowed, and the fingerprints allowed.
512
    push_findings: Vec<db::push_protection::FindingRow>,
513
    secret_bypasses: Vec<db::push_protection::Bypass>,
514
    /// The history scan (#277): its progress, its open findings and those
515
    /// marked resolved, and whether the hub runs it at all.
516
    history_scan: Option<db::push_protection::ScanRow>,
517
    history_findings: Vec<db::push_protection::HistoryRow>,
518
    resolved_findings: Vec<db::push_protection::HistoryRow>,
519
    history_scan_on: bool,
520
}
521
522
impl RepoSettingsPage {
5236×
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
5246×
        crate::backups::REPO_CHOICES
5256×
    }
526
}
527
528
/// One settings page, or not found when the viewer's role does not have it.
529141×
async fn settings_page(
530141×
    app: &Shared,
531141×
    shell: Shell,
532141×
    row: db::repos::RepoRow,
533141×
    role: Role,
534141×
    section: Section,
535141×
    notice: Option<String>,
536141×
    error: Option<String>,
537141×
) -> Result<Page<RepoSettingsPage>, Error> {
538141×
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
539141×
    if !section.shown(role, storage.is_some()) {
5408×
        return Err(Error::not_found(shell));
541133×
    }
542133×
    let nav = sidebar(role, storage.is_some());
543133×
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
544133×
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
54510×
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
546123×
        None => vec![],
547
    };
548133×
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
549133×
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550133×
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
551133×
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
552133×
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
553133×
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
554133×
    let diffusion_site = app.config.diffusion.follow.clone();
555133×
    let flow_enabled = crate::flow::enabled(app, &row);
556133×
    let site_admin = shell.is_admin;
557133×
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
558133×
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
559133×
        .await
560133×
        .map_err(|e| Error::from_db(shell.clone(), e))?
561133×
        .into_iter()
562133×
        .map(|r| {
5636×
            let own = r.scope_repo_id == Some(row.id);
5646×
            let serves = if own {
5654×
                "this repository".to_string()
5662×
            } else if r.scope_owner.is_empty() {
5672×
                "the whole site".to_string()
568
            } else {
5690×
                format!("{}'s repositories", r.scope_owner)
570
            };
5716×
            (r, serves, own)
5726×
        })
573133×
        .collect();
574133×
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
575133×
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
576133×
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
577133×
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
578133×
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
579133×
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
580133×
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
581133×
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
582133×
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
583133×
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
584133×
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
585133×
    Ok(Page(RepoSettingsPage {
586133×
        section,
587133×
        nav,
588133×
        push_findings,
589133×
        secret_bypasses,
590133×
        history_scan,
591133×
        history_findings,
592133×
        resolved_findings,
593133×
        history_scan_on,
594133×
        backup_owner,
595133×
        coverage,
596133×
        storage,
597133×
        assistants,
598133×
        variables,
599133×
        secrets,
600133×
        runners,
601133×
        runner_token: None,
602133×
        shell,
603133×
        repo,
604133×
        row,
605133×
        collaborators,
606133×
        teams,
607133×
        role,
608133×
        requirements,
609133×
        hooks,
610133×
        required_checks,
611133×
        flow_default,
612133×
        diffusion_site,
613133×
        flow_enabled,
614133×
        site_admin,
615133×
        own_runner,
616133×
        notice,
617133×
        error,
618133×
    }))
619141×
}
620
621
/// What a settings form did, carried to the page it redirects to.
622
#[derive(Deserialize, Default)]
623
pub struct Outcome {
624
    #[serde(default)]
625
    notice: Option<String>,
626
    #[serde(default)]
627
    error: Option<String>,
628
}
629
630
/// `GET /{owner}/{name}/settings`: the General page.
63112×
pub async fn settings(
63212×
    State(app): State<Shared>,
63312×
    viewer: Viewer,
63412×
    Path((owner, name)): Path<(String, String)>,
63512×
    Query(q): Query<Outcome>,
63612×
) -> Result<Page<RepoSettingsPage>, Error> {
63712×
    let path = format!("/{owner}/{name}/settings");
63812×
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
63912×
}
640
641
/// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings.
642
/// Some pages share their path with their form's `POST`, so the page is
643
/// the path's last segment.
644133×
pub async fn page(
645133×
    State(app): State<Shared>,
646133×
    viewer: Viewer,
647133×
    Path(params): Path<std::collections::HashMap<String, String>>,
648133×
    uri: Uri,
649133×
    Query(q): Query<Outcome>,
650133×
) -> Result<Page<RepoSettingsPage>, Error> {
651133×
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
652133×
    let path = format!("/{owner}/{name}/settings");
653133×
    let slug = uri.path().rsplit('/').next().unwrap_or("");
654133×
    let Some(section) = Section::parse(slug) else {
6551×
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
656
    };
657132×
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
658133×
}
659
660
/// A settings page for the repository's maintainers, or its admins: on an
661
/// archived repository, only its admins, who can bring it back.
662144×
async fn show(
663144×
    app: &Shared,
664144×
    viewer: &Viewer,
665144×
    path: &str,
666144×
    owner: &str,
667144×
    name: &str,
668144×
    section: Section,
669144×
    q: Outcome,
670144×
) -> Result<Page<RepoSettingsPage>, Error> {
671144×
    let shell = Shell::new(app, path, viewer);
672144×
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
673144×
    if role < Role::Maintain {
6745×
        return Err(Error::not_found(shell));
675139×
    }
676139×
    if row.archived && role < Role::Admin {
6770×
        return Err(super::archived(&shell));
678139×
    }
679139×
    settings_page(app, shell, row, role, section, q.notice, q.error).await
680144×
}
681
682
#[derive(Deserialize)]
683
pub struct GeneralForm {
684
    #[serde(rename = "_csrf")]
685
    csrf_token: String,
686
    description: String,
687
    default_bookmark: String,
688
    visibility: String,
689
    #[serde(default)]
690
    topics: String,
691
}
692
6936×
pub async fn update(
6946×
    State(app): State<Shared>,
6956×
    viewer: Viewer,
6966×
    Path((owner, name)): Path<(String, String)>,
6976×
    Form(f): Form<GeneralForm>,
6986×
) -> Result<Response, Error> {
6996×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7006×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
7016×
    csrf(&viewer, &f.csrf_token, &shell)?;
7026×
    let e = |e| Error::from_db(shell.clone(), e);
703
    // A new default must be a bookmark the repository has, or every page
704
    // that opens it finds nothing; an empty repository may name any, for
705
    // its first push to create.
7066×
    let wanted = f.default_bookmark.trim().to_string();
7076×
    if wanted != row.default_bookmark {
7082×
        let names: Vec<String> = app
7092×
            .repos
7102×
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
7112×
            .await
7122×
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
7132×
        if !names.is_empty() && !names.contains(&wanted) {
7142×
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
7152×
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
7161×
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
7171×
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
7181×
            return back(&row, Section::General, None, Some(msg));
7191×
        }
7204×
    }
7215×
    let topics = match db::repos::parse_topics(&f.topics) {
7224×
        Ok(t) => t,
7231×
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
724
    };
7254×
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
7264×
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
7274×
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
7284×
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
7290×
    }
7304×
    if role >= Role::Admin {
7314×
        if let Some(v) = Visibility::parse(&f.visibility) {
7324×
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
7330×
        }
7340×
    }
7354×
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
7364×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
7374×
    back(&row, Section::General, Some("Saved.".into()), None)
7386×
}
739
740
#[derive(Deserialize)]
741
pub struct CollaboratorForm {
742
    #[serde(rename = "_csrf")]
743
    csrf_token: String,
744
    #[serde(default)]
745
    username: String,
746
    #[serde(default)]
747
    team: String,
748
    #[serde(default)]
749
    role: String,
750
    #[serde(default)]
751
    remove: Option<String>,
752
}
753
7545×
pub async fn collaborator(
7555×
    State(app): State<Shared>,
7565×
    viewer: Viewer,
7575×
    Path((owner, name)): Path<(String, String)>,
7585×
    Form(f): Form<CollaboratorForm>,
7595×
) -> Result<Response, Error> {
7605×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7615×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7625×
    csrf(&viewer, &f.csrf_token, &shell)?;
7635×
    let e = |e| Error::from_db(shell.clone(), e);
7645×
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
7655×
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
7665×
            Some(a) => (Some(a.id), None, a.username),
7670×
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
768
        }
7690×
    } else if !f.team.trim().is_empty() {
7700×
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
7710×
        let team = match org {
7720×
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
7730×
            None => None,
774
        };
7750×
        match team {
7760×
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
7770×
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
778
        }
779
    } else {
7800×
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
781
    };
7825×
    if f.remove.is_some() {
7830×
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
7840×
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
7850×
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
7865×
    }
7875×
    let Some(new_role) = Role::parse(&f.role) else {
7880×
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
789
    };
7905×
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
7915×
    accounts::audit(
7925×
        &app.db,
7935×
        viewer.account(),
7945×
        "repo.collaborator.set",
7955×
        &format!("{owner}/{name}"),
7965×
        &format!("{label} {}", new_role.as_str()),
7975×
    )
7985×
    .await
7995×
    .ok();
8005×
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
8015×
}
802
803
#[derive(Deserialize)]
804
pub struct TransferForm {
805
    #[serde(rename = "_csrf")]
806
    csrf_token: String,
807
    owner: String,
808
    confirm: String,
809
}
810
811
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
812
/// user or organisation the admin may create repositories under (#108).
813
/// The old URL redirects and oxy remotes keep working.
8143×
pub async fn transfer(
8153×
    State(app): State<Shared>,
8163×
    viewer: Viewer,
8173×
    Path((owner, name)): Path<(String, String)>,
8183×
    Form(f): Form<TransferForm>,
8193×
) -> Result<Response, Error> {
8203×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8213×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8223×
    csrf(&viewer, &f.csrf_token, &shell)?;
8233×
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
8243×
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
8253×
    if f.confirm.trim() != repo.to_string() {
8261×
        return fail("Type the repository's full name to confirm.");
8272×
    }
8282×
    let to = f.owner.trim().trim_start_matches('@').to_string();
8292×
    let Some(dest) = RepoName::new(&to, &row.name) else {
8300×
        return fail("That is not an owner name.");
831
    };
8322×
    if dest.owner == row.owner {
8330×
        return fail("The repository is already there.");
8342×
    }
8352×
    let e = |e| Error::from_db(shell.clone(), e);
8362×
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
8372×
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
8382×
    if !known {
8391×
        return fail("There is no user or organisation of that name.");
8401×
    }
8411×
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
8420×
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
8431×
    }
8441×
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
8450×
        return fail("That owner already has a repository of this name.");
8461×
    }
847
    {
8481×
        let _guard = app.repos.exclusive_guard(&repo).await;
8491×
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
8501×
        crate::storage::renamed(&app, &repo, &dest);
8511×
        if app.wikis.exists(&repo) {
8520×
            app.wikis.rename(&repo, &dest).ok();
8531×
        }
8541×
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
8551×
        let mirror = crate::mirror::mirror_dir(&app, &repo);
8561×
        if mirror.is_dir() {
8570×
            let to = crate::mirror::mirror_dir(&app, &dest);
8580×
            if let Some(parent) = to.parent() {
8590×
                std::fs::create_dir_all(parent).ok();
8600×
            }
8610×
            std::fs::rename(&mirror, &to).ok();
8621×
        }
863
    }
8641×
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
8651×
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
8661×
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
8673×
}
868
869
#[derive(Deserialize)]
870
pub struct ArchiveForm {
871
    #[serde(rename = "_csrf")]
872
    csrf_token: String,
873
    /// `archive` or `unarchive`.
874
    action: String,
875
    #[serde(default)]
876
    reason: String,
877
}
878
879
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
880
/// back again (#108).
8813×
pub async fn archive(
8823×
    State(app): State<Shared>,
8833×
    viewer: Viewer,
8843×
    Path((owner, name)): Path<(String, String)>,
8853×
    Form(f): Form<ArchiveForm>,
8863×
) -> Result<Response, Error> {
8873×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8883×
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8893×
    csrf(&viewer, &f.csrf_token, &shell)?;
8903×
    let archive = match f.action.as_str() {
8913×
        "archive" => true,
8922×
        "unarchive" => false,
8931×
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
894
    };
8952×
    let reason: String = f.reason.trim().chars().take(500).collect();
8962×
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8972×
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
8982×
        .await
8992×
        .ok();
9002×
    row.archived = archive;
9012×
    row.archived_reason = if archive { reason } else { String::new() };
9022×
    let notice = if archive {
9031×
        "The repository is archived: read-only until it is brought back."
904
    } else {
9051×
        "The repository is back: writes are allowed again."
906
    };
9072×
    back(&row, Section::Advanced, Some(notice.into()), None)
9083×
}
909
910
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
9111×
pub async fn wiki_delete(
9121×
    State(app): State<Shared>,
9131×
    viewer: Viewer,
9141×
    Path((owner, name)): Path<(String, String)>,
9151×
    Form(f): Form<DeleteForm>,
9161×
) -> Result<Response, Error> {
9171×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9181×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9191×
    csrf(&viewer, &f.csrf_token, &shell)?;
9201×
    if f.confirm.trim() != repo.to_string() {
9210×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
9221×
    }
9231×
    if !crate::wiki::exists(&app, &repo) {
9240×
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
9251×
    }
9261×
    let _guard = app.wikis.exclusive_guard(&repo).await;
9271×
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
9281×
    drop(_guard);
9291×
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
9301×
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
9311×
}
932
933
#[derive(Deserialize)]
934
pub struct DeleteForm {
935
    #[serde(rename = "_csrf")]
936
    csrf_token: String,
937
    confirm: String,
938
}
939
9401×
pub async fn delete(
9411×
    State(app): State<Shared>,
9421×
    viewer: Viewer,
9431×
    Path((owner, name)): Path<(String, String)>,
9441×
    Form(f): Form<DeleteForm>,
9451×
) -> Result<Response, Error> {
9461×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9471×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9481×
    csrf(&viewer, &f.csrf_token, &shell)?;
9491×
    if f.confirm.trim() != repo.to_string() {
9500×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
9511×
    }
9521×
    let _guard = app.repos.exclusive_guard(&repo).await;
9531×
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
954
    // A repository kept in a bucket leaves its data there: the owner's.
9551×
    crate::storage::removed(&app, &repo);
9561×
    crate::wiki::trash(&app, &repo).ok();
9571×
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9581×
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9591×
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9601×
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9611×
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
9621×
    Ok(Redirect::to(&format!("/{owner}")).into_response())
9631×
}
964
965
#[derive(Deserialize)]
966
pub struct RequirementsForm {
967
    #[serde(rename = "_csrf")]
968
    csrf_token: String,
969
    #[serde(default)]
970
    required_approvals: i64,
971
    #[serde(default)]
972
    require_owners: Option<String>,
973
    #[serde(default)]
974
    allow_conflicts: Option<String>,
975
}
976
9772×
pub async fn requirements(
9782×
    State(app): State<Shared>,
9792×
    viewer: Viewer,
9802×
    Path((owner, name)): Path<(String, String)>,
9812×
    Form(f): Form<RequirementsForm>,
9822×
) -> Result<Response, Error> {
9832×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9842×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9852×
    csrf(&viewer, &f.csrf_token, &shell)?;
9862×
    let r = db::proposals::Requirements {
9872×
        required_approvals: f.required_approvals.max(0),
9882×
        require_owners: f.require_owners.is_some(),
9892×
        allow_conflicts: f.allow_conflicts.is_some(),
9902×
    };
9912×
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9922×
    accounts::audit(
9932×
        &app.db,
9942×
        viewer.account(),
9952×
        "repo.requirements",
9962×
        &format!("{owner}/{name}"),
9972×
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
9982×
    )
9992×
    .await
10002×
    .ok();
10012×
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
10022×
}
1003
10046×
pub async fn hooks(
10056×
    State(app): State<Shared>,
10066×
    viewer: Viewer,
10076×
    Path((owner, name)): Path<(String, String)>,
10086×
    Form(f): Form<super::webhooks::HookForm>,
10096×
) -> Result<Response, Error> {
10106×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10116×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
10126×
    csrf(&viewer, &f.csrf_token, &shell)?;
10136×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
10146×
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
10155×
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
10161×
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
1017
    }
10186×
}
1019
10200×
pub async fn deliveries(
10210×
    State(app): State<Shared>,
10220×
    viewer: Viewer,
10230×
    Path((owner, name, id)): Path<(String, String, i64)>,
10240×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
10250×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
10260×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
10270×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
10280×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
10290×
}
1030
10310×
pub async fn redeliver(
10320×
    State(app): State<Shared>,
10330×
    viewer: Viewer,
10340×
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
10350×
    Form(f): Form<super::session::CsrfForm>,
10360×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
10370×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
10380×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
10390×
    csrf(&viewer, &f.csrf, &shell)?;
10400×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
10410×
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10420×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
10430×
}
1044
1045
#[derive(Deserialize)]
1046
pub struct ChecksForm {
1047
    #[serde(rename = "_csrf")]
1048
    csrf_token: String,
1049
    #[serde(default)]
1050
    required_checks: String,
1051
}
1052
10532×
pub async fn required_checks(
10542×
    State(app): State<Shared>,
10552×
    viewer: Viewer,
10562×
    Path((owner, name)): Path<(String, String)>,
10572×
    Form(f): Form<ChecksForm>,
10582×
) -> Result<Response, Error> {
10592×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10602×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10612×
    csrf(&viewer, &f.csrf_token, &shell)?;
10622×
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
10632×
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10642×
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
10652×
}
1066
1067
#[derive(Deserialize)]
1068
pub struct CoverageForm {
1069
    #[serde(rename = "_csrf")]
1070
    csrf_token: String,
1071
    #[serde(default)]
1072
    min: String,
1073
    #[serde(default)]
1074
    max_drop: String,
1075
}
1076
1077
/// Settings → Coverage (#113): the rules the `coverage` check applies.
10781×
pub async fn coverage(
10791×
    State(app): State<Shared>,
10801×
    viewer: Viewer,
10811×
    Path((owner, name)): Path<(String, String)>,
10821×
    Form(f): Form<CoverageForm>,
10831×
) -> Result<Response, Error> {
10841×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10851×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10861×
    csrf(&viewer, &f.csrf_token, &shell)?;
10872×
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
10882×
        let v = v.trim().trim_end_matches('%').trim();
10892×
        if v.is_empty() {
10901×
            return Ok(None);
10911×
        }
10921×
        match v.parse::<f64>() {
10931×
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
10940×
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
1095
        }
10962×
    };
10971×
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
10981×
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
10990×
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
1100
    };
11011×
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11021×
    accounts::audit(
11031×
        &app.db,
11041×
        viewer.account(),
11051×
        "repo.coverage_rules",
11061×
        &format!("{owner}/{name}"),
11071×
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
11081×
    )
11091×
    .await
11101×
    .ok();
11111×
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
11121×
}
1113
1114
#[derive(Deserialize)]
1115
pub struct StorageForm {
1116
    #[serde(rename = "_csrf")]
1117
    csrf_token: String,
1118
    /// `check`, or `credentials` (with the new ones).
1119
    action: String,
1120
    #[serde(flatten)]
1121
    bucket: BucketForm,
1122
}
1123
1124
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
1125
/// its credentials once the new ones pass the check.
11260×
pub async fn storage(
11270×
    State(app): State<Shared>,
11280×
    viewer: Viewer,
11290×
    Path((owner, name)): Path<(String, String)>,
11300×
    Form(f): Form<StorageForm>,
11310×
) -> Result<Response, Error> {
11320×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11330×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
11340×
    csrf(&viewer, &f.csrf_token, &shell)?;
11350×
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
11360×
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1137
    };
11380×
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
11390×
    let outcome = match f.action.as_str() {
11400×
        "credentials" => match f.bucket.credentials(&scheme) {
11410×
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
11420×
            Err(e) => Err(format!("Credentials: {e}")),
1143
        },
11440×
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
1145
    };
11460×
    match outcome {
11470×
        Ok(notice) => {
11480×
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
11490×
            back(&row, Section::Storage, Some(notice), None)
1150
        }
11510×
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1152
    }
11530×
}
1154
1155
#[derive(Deserialize)]
1156
pub struct MirrorForm {
1157
    #[serde(rename = "_csrf")]
1158
    csrf_token: String,
1159
    #[serde(default)]
1160
    enabled: Option<String>,
1161
}
1162
1163
#[derive(Deserialize)]
1164
pub struct BackupForm {
1165
    #[serde(rename = "_csrf")]
1166
    csrf_token: String,
1167
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
1168
    #[serde(default)]
1169
    backup: String,
1170
}
1171
1172
/// This repository's backup (docs/backups.md §5): with its owner's, left
1173
/// out, or at least hourly or daily. Its owners and admins decide.
11743×
pub async fn backup(
11753×
    State(app): State<Shared>,
11763×
    viewer: Viewer,
11773×
    Path((owner, name)): Path<(String, String)>,
11783×
    Form(f): Form<BackupForm>,
11793×
) -> Result<Response, Error> {
11803×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11813×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11823×
    csrf(&viewer, &f.csrf_token, &shell)?;
118312×
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
11841×
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
11852×
    }
11862×
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11872×
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
11882×
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
11892×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
11902×
    let notice = match f.backup.as_str() {
11912×
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
11920×
        "" => "This repository is backed up with its owner's.".to_string(),
11930×
        every => {
11940×
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1195
        }
1196
    };
11972×
    back(&row, Section::Backup, Some(notice), None)
11983×
}
1199
1200
#[derive(Deserialize)]
1201
pub struct DiffusionForm {
1202
    #[serde(rename = "_csrf")]
1203
    csrf_token: String,
1204
    /// `off`, `green`, `any`, or `default`.
1205
    diffusion: String,
1206
    /// `auto` or `propose` (absent keeps it as it is).
1207
    #[serde(default)]
1208
    land: String,
1209
}
1210
1211
/// Whether this repository follows the upstreams it pins (Diffusion,
1212
/// #148): its owners and admins decide, since it opens proposals here.
12131×
pub async fn diffusion(
12141×
    State(app): State<Shared>,
12151×
    viewer: Viewer,
12161×
    Path((owner, name)): Path<(String, String)>,
12171×
    Form(f): Form<DiffusionForm>,
12181×
) -> Result<Response, Error> {
12191×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12201×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12211×
    csrf(&viewer, &f.csrf_token, &shell)?;
12221×
    let setting = match f.diffusion.as_str() {
12231×
        "off" | "green" | "any" => f.diffusion.as_str(),
12240×
        "default" => "",
12250×
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1226
    };
12271×
    let land = match f.land.as_str() {
12281×
        "auto" | "propose" => Some(f.land.as_str()),
12290×
        "" => None,
12300×
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1231
    };
12321×
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12331×
    if let Some(l) = land {
12341×
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12350×
    }
12361×
    let what =
12371×
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
12381×
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
12391×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1240
    // Turned to land by itself: any bump already green lands now.
12411×
    if land == Some("auto") {
12420×
        let (app, row) = (app.clone(), row.clone());
12430×
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
12441×
    }
12451×
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
12460×
        let (app, row) = (app.clone(), row.clone());
12470×
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
12481×
    }
12491×
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
12501×
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1251
        crate::diffusion::Follow::Green => {
12520×
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1253
        }
12540×
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1255
    };
12561×
    back(&row, Section::Diffusion, Some(notice.into()), None)
12571×
}
1258
1259
#[derive(Deserialize)]
1260
pub struct AgentGatesForm {
1261
    #[serde(rename = "_csrf")]
1262
    csrf_token: String,
1263
    /// `on` when the box is ticked.
1264
    #[serde(default)]
1265
    agents: String,
1266
}
1267
1268
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1269
/// agent's token may approve or reject this repository's approval gates;
1270
/// for maintainers, audited.
12711×
pub async fn agent_gates(
12721×
    State(app): State<Shared>,
12731×
    viewer: Viewer,
12741×
    Path((owner, name)): Path<(String, String)>,
12751×
    Form(f): Form<AgentGatesForm>,
12761×
) -> Result<Response, Error> {
12771×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12781×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12791×
    csrf(&viewer, &f.csrf_token, &shell)?;
12801×
    let on = f.agents == "on";
12811×
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12821×
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
12831×
        .await
12841×
        .ok();
12851×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
12861×
    let notice = if on {
12871×
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1288
    } else {
12890×
        "Approval gates here are decided by people: agents' tokens are refused."
1290
    };
12911×
    back(&row, Section::Gates, Some(notice.into()), None)
12921×
}
1293
1294
#[derive(Deserialize)]
1295
pub struct PushProtectionForm {
1296
    #[serde(rename = "_csrf")]
1297
    csrf_token: String,
1298
    /// `on` when the box is ticked.
1299
    #[serde(default)]
1300
    protect: String,
1301
}
1302
1303
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1304
/// pushes adding what looks like a secret are refused; for maintainers,
1305
/// audited.
13064×
pub async fn push_protection(
13074×
    State(app): State<Shared>,
13084×
    viewer: Viewer,
13094×
    Path((owner, name)): Path<(String, String)>,
13104×
    Form(f): Form<PushProtectionForm>,
13114×
) -> Result<Response, Error> {
13124×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13134×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13144×
    csrf(&viewer, &f.csrf_token, &shell)?;
13154×
    let on = f.protect == "on";
13164×
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13174×
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
13184×
        .await
13194×
        .ok();
13204×
    if on {
13212×
        // What was pushed while it was off is scanned now (#277).
13222×
        crate::history_scan::wake(&app, row.id);
13232×
    }
13244×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
13254×
    let notice = if on {
13262×
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1327
    } else {
13282×
        "Push protection is off: pushes are no longer scanned for secrets here."
1329
    };
13304×
    back(&row, Section::PushProtection, Some(notice.into()), None)
13314×
}
1332
1333
#[derive(Deserialize)]
1334
pub struct SecretBypassForm {
1335
    #[serde(rename = "_csrf")]
1336
    csrf_token: String,
1337
    fingerprint: String,
1338
    #[serde(default)]
1339
    reason: String,
1340
}
1341
1342
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1343
/// push protection finding through, by its fingerprint, with a reason; for
1344
/// maintainers, audited as `secret.bypass`.
13455×
pub async fn secret_bypass(
13465×
    State(app): State<Shared>,
13475×
    viewer: Viewer,
13485×
    Path((owner, name)): Path<(String, String)>,
13495×
    Form(f): Form<SecretBypassForm>,
13505×
) -> Result<Response, Error> {
13515×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13525×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13534×
    csrf(&viewer, &f.csrf_token, &shell)?;
13544×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
13554×
    let reason = f.reason.trim();
135664×
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
13570×
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
13584×
    } else if reason.is_empty() {
13591×
        Some("Say why this is not a secret (an example key, a test fixture): the reason is kept with the fingerprint and in the audit log.")
13603×
    } else if reason.chars().count() > 500 {
13610×
        Some("Keep the reason under 500 characters.")
1362
    } else {
13633×
        None
1364
    };
13654×
    if let Some(e) = error {
13661×
        return back(&row, Section::PushProtection, None, Some(e.into()));
13673×
    }
13683×
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
13693×
        .await
13703×
        .map_err(|e| Error::from_db(shell.clone(), e))?
13713×
        .unwrap_or_default();
13723×
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
13733×
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13743×
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
13753×
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
13763×
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
13773×
    back(&row, Section::PushProtection, Some(notice), None)
13785×
}
1379
1380
#[derive(Deserialize)]
1381
pub struct BypassRemoveForm {
1382
    #[serde(rename = "_csrf")]
1383
    csrf_token: String,
1384
    fingerprint: String,
1385
}
1386
1387
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1388
/// take back an allowed fingerprint, so a push adding that value is refused
1389
/// again; for maintainers, audited as `secret.bypass_removed`.
13903×
pub async fn secret_bypass_remove(
13913×
    State(app): State<Shared>,
13923×
    viewer: Viewer,
13933×
    Path((owner, name)): Path<(String, String)>,
13943×
    Form(f): Form<BypassRemoveForm>,
13953×
) -> Result<Response, Error> {
13963×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13973×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13982×
    csrf(&viewer, &f.csrf_token, &shell)?;
13992×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
14002×
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14012×
    let Some(b) = removed else {
14021×
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1403
    };
14041×
    let detail = if b.rule.is_empty() {
14050×
        format!("{fingerprint} (was: {})", b.reason)
1406
    } else {
14071×
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1408
    };
14091×
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
14101×
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
14111×
    back(&row, Section::PushProtection, Some(notice), None)
14123×
}
1413
1414
#[derive(Deserialize)]
1415
pub struct SecretResolveForm {
1416
    #[serde(rename = "_csrf")]
1417
    csrf_token: String,
1418
    finding: i64,
1419
    #[serde(default)]
1420
    reason: String,
1421
}
1422
1423
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1424
/// finding of the history scan resolved (the secret was revoked or
1425
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
14264×
pub async fn secret_resolve(
14274×
    State(app): State<Shared>,
14284×
    viewer: Viewer,
14294×
    Path((owner, name)): Path<(String, String)>,
14304×
    Form(f): Form<SecretResolveForm>,
14314×
) -> Result<Response, Error> {
14324×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14334×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14343×
    csrf(&viewer, &f.csrf_token, &shell)?;
14353×
    let reason = f.reason.trim();
14363×
    let error = if reason.is_empty() {
14371×
        Some("Say how it was resolved (the key was revoked, the token rotated): the reason is kept with the finding and in the audit log.")
14382×
    } else if reason.chars().count() > 500 {
14390×
        Some("Keep the reason under 500 characters.")
1440
    } else {
14412×
        None
1442
    };
14433×
    if let Some(e) = error {
14441×
        return back(&row, Section::PushProtection, None, Some(e.into()));
14452×
    }
14462×
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
14472×
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14482×
    let Some(found) = done else {
14491×
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1450
    };
14511×
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
14521×
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
14531×
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
14541×
    back(&row, Section::PushProtection, Some(notice), None)
14554×
}
1456
1457
#[derive(Deserialize)]
1458
pub struct SecretScanForm {
1459
    #[serde(rename = "_csrf")]
1460
    csrf_token: String,
1461
}
1462
1463
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1464
/// whole history again now, with the current rules; for maintainers,
1465
/// audited as `secret.history_scan`.
14662×
pub async fn secret_scan(
14672×
    State(app): State<Shared>,
14682×
    viewer: Viewer,
14692×
    Path((owner, name)): Path<(String, String)>,
14702×
    Form(f): Form<SecretScanForm>,
14712×
) -> Result<Response, Error> {
14722×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14732×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14741×
    csrf(&viewer, &f.csrf_token, &shell)?;
14751×
    if !row.push_protection {
14760×
        return back(
14770×
            &row,
14780×
            Section::PushProtection,
14790×
            None,
14800×
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1481
        );
14821×
    }
14831×
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14841×
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
14851×
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
14861×
    back(&row, Section::PushProtection, Some(notice.into()), None)
14872×
}
1488
1489
#[derive(Deserialize)]
1490
pub struct SupersedeForm {
1491
    #[serde(rename = "_csrf")]
1492
    csrf_token: String,
1493
    /// `on` when the box is ticked: superseded runs already running are
1494
    /// cancelled too.
1495
    #[serde(default)]
1496
    running: String,
1497
}
1498
1499
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1500
/// a new push or proposal run cancels the older runs it supersedes that are
1501
/// already running, or only those still queued; for maintainers, audited.
15021×
pub async fn flow_supersede(
15031×
    State(app): State<Shared>,
15041×
    viewer: Viewer,
15051×
    Path((owner, name)): Path<(String, String)>,
15061×
    Form(f): Form<SupersedeForm>,
15071×
) -> Result<Response, Error> {
15081×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15091×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15101×
    csrf(&viewer, &f.csrf_token, &shell)?;
15111×
    let keep = f.running != "on";
15121×
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15131×
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
15141×
        .await
15151×
        .ok();
15161×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15171×
    let notice = if keep {
15180×
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1519
    } else {
15201×
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1521
    };
15221×
    back(&row, Section::Runs, Some(notice.into()), None)
15231×
}
1524
1525
#[derive(Deserialize)]
1526
pub struct ReuseForm {
1527
    #[serde(rename = "_csrf")]
1528
    csrf_token: String,
1529
    /// `on` when the box is ticked.
1530
    #[serde(default)]
1531
    reuse: String,
1532
}
1533
1534
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1535
/// push run reuses a job's pass on the same state; for maintainers,
1536
/// audited.
15370×
pub async fn flow_reuse(
15380×
    State(app): State<Shared>,
15390×
    viewer: Viewer,
15400×
    Path((owner, name)): Path<(String, String)>,
15410×
    Form(f): Form<ReuseForm>,
15420×
) -> Result<Response, Error> {
15430×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15440×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15450×
    csrf(&viewer, &f.csrf_token, &shell)?;
15460×
    let on = f.reuse == "on";
15470×
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15480×
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
15490×
    let notice = if on {
15500×
        "A push run now reuses a job's pass on the same state."
1551
    } else {
15520×
        "Every job of every push now runs, passed before or not."
1553
    };
15540×
    back(&row, Section::Runs, Some(notice.into()), None)
15550×
}
1556
1557
#[derive(Deserialize)]
1558
pub struct SkipForm {
1559
    #[serde(rename = "_csrf")]
1560
    csrf_token: String,
1561
    /// `any`, `others` or `never`.
1562
    skip: String,
1563
}
1564
1565
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1566
/// may skip its run with `[skip flow]` in its message; for maintainers,
1567
/// audited.
15682×
pub async fn flow_skip(
15692×
    State(app): State<Shared>,
15702×
    viewer: Viewer,
15712×
    Path((owner, name)): Path<(String, String)>,
15722×
    Form(f): Form<SkipForm>,
15732×
) -> Result<Response, Error> {
15742×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15752×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15762×
    csrf(&viewer, &f.csrf_token, &shell)?;
15772×
    let notice = match f.skip.as_str() {
15782×
        "any" => "A push may now skip its run on any bookmark.".to_string(),
15792×
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
15801×
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
15811×
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1582
    };
15831×
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15841×
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
15851×
    back(&row, Section::Runs, Some(notice), None)
15862×
}
1587
1588
#[derive(Deserialize)]
1589
pub struct PriorityForm {
1590
    #[serde(rename = "_csrf")]
1591
    csrf_token: String,
1592
    /// `low`, `normal`, `high`, or `default`.
1593
    priority: String,
1594
}
1595
1596
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1597
/// level. Maintainers choose low, normal or the default; only site
1598
/// administrators raise a repository to high.
15992×
pub async fn flow_priority(
16002×
    State(app): State<Shared>,
16012×
    viewer: Viewer,
16022×
    Path((owner, name)): Path<(String, String)>,
16032×
    Form(f): Form<PriorityForm>,
16042×
) -> Result<Response, Error> {
16052×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16062×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
16072×
    csrf(&viewer, &f.csrf_token, &shell)?;
16082×
    let level = match f.priority.as_str() {
16092×
        "low" | "normal" | "high" => f.priority.as_str(),
16100×
        "default" => "",
16110×
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1612
    };
16132×
    if level == "high" && !viewer.is_admin() {
16140×
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
16152×
    }
16162×
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
16172×
    accounts::audit(
16182×
        &app.db,
16192×
        viewer.account(),
16202×
        "repo.flow_priority",
16212×
        &format!("{owner}/{name}"),
16222×
        if level.is_empty() { "default" } else { level },
1623
    )
16242×
    .await
16252×
    .ok();
16262×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
16272×
    let notice = if level.is_empty() {
16280×
        "Its jobs follow the organisation's and the site's priority.".to_string()
1629
    } else {
16302×
        format!("Its jobs are {level} priority.")
1631
    };
16322×
    back(&row, Section::Runs, Some(notice), None)
16332×
}
1634
1635
#[derive(Deserialize)]
1636
pub struct FlowForm {
1637
    #[serde(rename = "_csrf")]
1638
    csrf_token: String,
1639
    /// `on`, `off`, or `default`.
1640
    flow: String,
1641
}
1642
1643
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1644
/// anything; the repository's maintainers may turn it on on their own
1645
/// runners (`own`), when the repository or its owner has one, or off (#225).
1646
/// Only site administrators let the site's runners take its jobs.
16479×
pub async fn flow(
16489×
    State(app): State<Shared>,
16499×
    viewer: Viewer,
16509×
    Path((owner, name)): Path<(String, String)>,
16519×
    Form(f): Form<FlowForm>,
16529×
) -> Result<Response, Error> {
16539×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16549×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
16559×
    csrf(&viewer, &f.csrf_token, &shell)?;
16569×
    let setting = match f.flow.as_str() {
16579×
        "on" => "on",
16586×
        "own" => "own",
16594×
        "off" => "off",
16603×
        "default" => "",
16612×
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1662
    };
16637×
    if !viewer.is_admin() {
16644×
        if matches!(setting, "on" | "") {
16652×
            return back(
16662×
                &row,
16672×
                Section::Pipelines,
16682×
                None,
16692×
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1670
            );
16712×
        }
16722×
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16731×
            return back(
16741×
                &row,
16751×
                Section::Pipelines,
16761×
                None,
16771×
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1678
            );
16791×
        }
16803×
    }
16814×
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
16824×
    let label = if setting.is_empty() { "default" } else { setting };
16834×
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
16844×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
16854×
    let notice = if row.flow == "own" {
16861×
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
16873×
    } else if crate::flow::enabled(&app, &row) {
16881×
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1689
    } else {
16902×
        "OxygenFlow is off for this repository."
1691
    };
16924×
    back(&row, Section::Pipelines, Some(notice.into()), None)
16939×
}
1694
16952×
pub async fn mirror(
16962×
    State(app): State<Shared>,
16972×
    viewer: Viewer,
16982×
    Path((owner, name)): Path<(String, String)>,
16992×
    Form(f): Form<MirrorForm>,
17002×
) -> Result<Response, Error> {
17012×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17022×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
17032×
    csrf(&viewer, &f.csrf_token, &shell)?;
17042×
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
17052×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
17062×
    if row.git_mirror {
17071×
        crate::mirror::refresh(&app, &row).await.ok();
17081×
    }
17092×
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
17102×
        .await
17112×
        .ok();
17122×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
17132×
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
17142×
    back(&row, Section::GitMirror, Some(notice.into()), None)
17152×
}
1716
1717
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1718
/// token for a runner that serves this repository only (oxygenhub#41).
17192×
pub async fn runner_token(
17202×
    State(app): State<Shared>,
17212×
    viewer: Viewer,
17222×
    Path((owner, name)): Path<(String, String)>,
17232×
    Form(f): Form<super::session::CsrfForm>,
17242×
) -> Result<Page<RepoSettingsPage>, Error> {
17252×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17262×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17272×
    csrf(&viewer, &f.csrf, &shell)?;
17282×
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
17292×
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
17302×
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
17312×
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
17322×
        .await
17332×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
17342×
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
17352×
    let mut page =
17362×
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
17372×
    page.0.runner_token = Some(token);
17382×
    Ok(page)
17392×
}
1740
1741
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1742
/// repository's own runners, when it is running nothing.
17432×
pub async fn runner_remove(
17442×
    State(app): State<Shared>,
17452×
    viewer: Viewer,
17462×
    Path((owner, name, id)): Path<(String, String, i64)>,
17472×
    Form(f): Form<super::session::CsrfForm>,
17482×
) -> Result<Response, Error> {
17492×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17502×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17512×
    csrf(&viewer, &f.csrf, &shell)?;
17522×
    let e = |e| Error::from_db(shell.clone(), e);
17532×
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
17541×
        return Err(Error::not_found(shell));
1755
    };
17561×
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
17570×
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
17581×
    }
17591×
    db::runners::remove(&app.db, id).await.map_err(e)?;
17601×
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
17611×
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
17622×
}
1763
1764404×
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1765404×
    crate::levels::At::repo(row)
1766404×
}
1767
1768
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1769
/// and prompt (#97), for its admins.
17702×
pub async fn assistants(
17712×
    State(app): State<Shared>,
17722×
    viewer: Viewer,
17732×
    Path((owner, name)): Path<(String, String)>,
17742×
    Form(f): Form<super::assistants::AssistantForm>,
17752×
) -> Result<Response, Error> {
17762×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17772×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17781×
    csrf(&viewer, &f.csrf_token, &shell)?;
17791×
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17801×
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
17810×
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1782
    }
17832×
}
1784
1785
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1786
/// variables (#102), for its admins.
17873×
pub async fn variables(
17883×
    State(app): State<Shared>,
17893×
    viewer: Viewer,
17903×
    Path((owner, name)): Path<(String, String)>,
17913×
    Form(f): Form<super::variables::VariableForm>,
17923×
) -> Result<Response, Error> {
17933×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17943×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17953×
    csrf(&viewer, &f.csrf_token, &shell)?;
17963×
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17972×
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
17981×
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1799
    }
18003×
}
1801
1802
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1803
/// (#107), for its admins.
18041×
pub async fn secrets(
18051×
    State(app): State<Shared>,
18061×
    viewer: Viewer,
18071×
    Path((owner, name)): Path<(String, String)>,
18081×
    Form(f): Form<super::secrets::SecretForm>,
18091×
) -> Result<Response, Error> {
18101×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
18111×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
18121×
    csrf(&viewer, &f.csrf_token, &shell)?;
18131×
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
18141×
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
18150×
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1816
    }
18171×
}
1818
1819
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
18200×
pub async fn assistant_models(
18210×
    State(app): State<Shared>,
18220×
    viewer: Viewer,
18230×
    Path((owner, name)): Path<(String, String)>,
18240×
    Form(f): Form<super::assistants::ModelsForm>,
18250×
) -> Result<axum::Json<serde_json::Value>, Error> {
18260×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
18270×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
18280×
    csrf(&viewer, &f.csrf_token, &shell)?;
18290×
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
18300×
}
1831
1832
/// Whether a runner serves this repository that is not the site's: one
1833
/// scoped to it or to its owner (#225).
1834135×
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1835135×
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1836135×
}