OXYGENHUB

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

952 of 1198 lines run, 101 of 380 functions · run #476 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
}
23
2452×
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
2552×
    let mut owners = vec![me.username.clone()];
2652×
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
2752×
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
280×
    }
2952×
    owners
3052×
}
31
3224×
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
3324×
    let shell = Shell::new(&app, "/new", &viewer);
3424×
    let me = require(&viewer, &shell)?;
3524×
    let owners = owners_for(&app, &me.account).await;
3624×
    Ok(Page(NewRepoPage { shell, owners, error: None }))
3724×
}
38
39
#[derive(Deserialize)]
40
pub struct NewRepoForm {
41
    #[serde(rename = "_csrf")]
42
    csrf_token: String,
43
    owner: String,
44
    name: String,
45
    #[serde(default)]
46
    description: String,
47
    #[serde(default = "default_visibility")]
48
    visibility: String,
49
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
50
    #[serde(default)]
51
    storage: String,
52
    #[serde(flatten)]
53
    bucket: BucketForm,
54
}
55
56
/// A bucket and the credentials for it, as the New repository form and
57
/// Settings → Storage give them; only the kind the URL names is read.
58
#[derive(Deserialize, Default)]
59
pub struct BucketForm {
60
    #[serde(default)]
61
    store_url: String,
62
    #[serde(default)]
63
    access_key_id: String,
64
    #[serde(default)]
65
    secret_access_key: String,
66
    #[serde(default)]
67
    service_account_key: String,
68
    #[serde(default)]
69
    tenant_id: String,
70
    #[serde(default)]
71
    client_id: String,
72
    #[serde(default)]
73
    client_secret: String,
74
}
75
76
impl BucketForm {
771×
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
781×
        crate::storage::credentials_from(
791×
            scheme,
801×
            &self.access_key_id,
811×
            &self.secret_access_key,
821×
            &self.service_account_key,
831×
            &self.tenant_id,
841×
            &self.client_id,
851×
            &self.client_secret,
86
        )
871×
    }
88
}
89
9017×
fn default_visibility() -> String {
9117×
    "public".into()
9217×
}
93
9425×
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
9525×
    let shell = Shell::new(&app, "/new", &viewer);
9625×
    let me = require(&viewer, &shell)?;
9725×
    csrf(&viewer, &f.csrf_token, &shell)?;
9825×
    let owners = owners_for(&app, &me.account).await;
9925×
    let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response();
10025×
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
1011×
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
102
    };
10324×
    if name.name.ends_with(crate::wiki::SUFFIX) {
1041×
        return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki."));
10523×
    }
10623×
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1070×
        return Ok(fail("You cannot create repositories under that owner."));
10823×
    }
10923×
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
11023×
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
11123×
        || app.repos.exists(&name)
112
    {
1130×
        return Ok(fail("That repository exists."));
11423×
    }
115
    // An organisation's storage policy (oxygenhub#96).
11623×
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1175×
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1185×
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
1195×
        if let Err(e) = policy.permits(&org.name, store) {
1202×
            return Ok(fail(&format!("{e}.")));
1213×
        }
12218×
    }
12321×
    if f.storage == "bucket" {
124
        // Kept in the owner's bucket (oxygenhub#96): checked before
125
        // anything is made, and nothing is left behind when it fails.
1266×
        let url = f.bucket.store_url.trim();
1276×
        let parsed = match crate::storage::check_url(url) {
1281×
            Ok(p) => p,
1295×
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
130
        };
1311×
        let credentials = match f.bucket.credentials(&parsed.scheme) {
1320×
            Ok(c) => c,
1331×
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
134
        };
1350×
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
1360×
            .await
1370×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
1380×
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
1390×
            db::repos::delete(&app.db, row.id).await.ok();
1400×
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
1410×
        }
1420×
        accounts::audit(
1430×
            &app.db,
1440×
            Some(&me.account),
1450×
            "repo.created",
1460×
            &name.to_string(),
1470×
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
1480×
        )
1490×
        .await
1500×
        .ok();
1510×
        return Ok(Redirect::to(&name.url()).into_response());
15215×
    }
15315×
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
15415×
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
15515×
        .await
15615×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
15715×
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
15815×
    Ok(Redirect::to(&name.url()).into_response())
15925×
}
160
161
/// One page of a repository's settings (#278), at
162
/// `/{owner}/{name}/settings/{slug}`; `/settings` is General.
163
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164
pub enum Section {
165
    General,
166
    Collaborators,
167
    Landing,
168
    Checks,
169
    PushProtection,
170
    Pipelines,
171
    Runs,
172
    Gates,
173
    Runners,
174
    Variables,
175
    Secrets,
176
    Coverage,
177
    Webhooks,
178
    Assistants,
179
    GitMirror,
180
    Diffusion,
181
    Storage,
182
    Backup,
183
    Advanced,
184
}
185
186
/// The sidebar: each group's heading and its pages, in order.
187
const GROUPS: &[(&str, &[Section])] = &[
188
    ("General", &[Section::General, Section::Collaborators]),
189
    ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]),
190
    (
191
        "Flow",
192
        &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage],
193
    ),
194
    ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]),
195
    ("Data", &[Section::Storage, Section::Backup]),
196
    ("Danger zone", &[Section::Advanced]),
197
];
198
199
impl Section {
2005905×
    pub fn slug(self) -> &'static str {
2015905×
        match self {
202402×
            Section::General => "general",
203372×
            Section::Collaborators => "collaborators",
204388×
            Section::Landing => "landing",
205385×
            Section::Checks => "checks",
206392×
            Section::PushProtection => "push-protection",
207366×
            Section::Pipelines => "flow",
208343×
            Section::Runs => "runs",
209331×
            Section::Gates => "gates",
210302×
            Section::Runners => "runners",
211299×
            Section::Variables => "variables",
212291×
            Section::Secrets => "secrets",
213313×
            Section::Coverage => "coverage",
214289×
            Section::Webhooks => "webhooks",
215274×
            Section::Assistants => "assistants",
216296×
            Section::GitMirror => "git-mirror",
217291×
            Section::Diffusion => "diffusion",
21821×
            Section::Storage => "storage",
219289×
            Section::Backup => "backup",
220261×
            Section::Advanced => "advanced",
221
        }
2225905×
    }
223
2242421×
    pub fn title(self) -> &'static str {
2252421×
        match self {
226133×
            Section::General => "General",
227126×
            Section::Collaborators => "Collaborators",
228136×
            Section::Landing => "Landing rules",
229138×
            Section::Checks => "Required checks",
230153×
            Section::PushProtection => "Push protection",
231152×
            Section::Pipelines => "Pipelines",
232141×
            Section::Runs => "Runs & priority",
233136×
            Section::Gates => "Gates & agents",
234126×
            Section::Runners => "Runners",
235125×
            Section::Variables => "Variables",
236123×
            Section::Secrets => "Secrets",
237136×
            Section::Coverage => "Coverage",
238129×
            Section::Webhooks => "Webhooks",
239124×
            Section::Assistants => "AI assistants",
240137×
            Section::GitMirror => "Git mirror",
241136×
            Section::Diffusion => "Diffusion",
2420×
            Section::Storage => "Storage",
243139×
            Section::Backup => "Backup",
244131×
            Section::Advanced => "Advanced",
245
        }
2462421×
    }
247
248133×
    pub fn parse(slug: &str) -> Option<Section> {
2491232×
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
250133×
    }
251
252
    /// Whether only the repository's admins use it; its maintainers use
253
    /// the rest. The roles are those of each page's forms.
2542668×
    fn admin_only(self) -> bool {
2551552×
        matches!(
2562668×
            self,
257
            Section::Collaborators
258
                | Section::Runners
259
                | Section::Variables
260
                | Section::Secrets
261
                | Section::Webhooks
262
                | Section::Assistants
263
                | Section::Storage
264
                | Section::Advanced
265
        )
2662668×
    }
267
268
    /// Whether this viewer has the page: Storage only for a repository kept
269
    /// in a bucket.
2702668×
    fn shown(self, role: Role, in_bucket: bool) -> bool {
2712668×
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
2722668×
    }
273
}
274
275
/// The sidebar a viewer sees: the groups with at least one page for them.
276133×
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
277133×
    GROUPS
278133×
        .iter()
2792527×
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
280798×
        .filter(|(_, pages)| !pages.is_empty())
281133×
        .collect()
282133×
}
283
284
/// Back to the page that holds a settings form, with what happened (#278).
28566×
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
28666×
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
28766×
    if let Some(n) = notice {
28849×
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
28949×
    } else if let Some(e) = error {
29017×
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
29117×
    }
29266×
    Ok(Redirect::to(&to).into_response())
29366×
}
294
295
#[derive(Template)]
296
#[template(path = "repo_settings.html")]
297
pub struct RepoSettingsPage {
298
    /// The page shown, and the sidebar's groups of pages.
299
    section: Section,
300
    nav: Vec<(&'static str, Vec<Section>)>,
301
    shell: Shell,
302
    repo: RepoShell,
303
    row: db::repos::RepoRow,
304
    collaborators: Vec<accounts::Collaborator>,
305
    teams: Vec<accounts::Team>,
306
    role: Role,
307
    requirements: db::proposals::Requirements,
308
    hooks: Vec<super::webhooks::HookRow>,
309
    required_checks: String,
310
    /// Whether hub.toml alone would run Flow here, and whether it runs.
311
    flow_default: bool,
312
    /// hub.toml's `[diffusion] follow` (#148).
313
    diffusion_site: String,
314
    flow_enabled: bool,
315
    site_admin: bool,
316
    /// Whether the repository or its owner has a runner of its own (#225).
317
    own_runner: bool,
318
    notice: Option<String>,
319
    error: Option<String>,
320
    /// Runners that may take this repository's jobs: (runner, what it
321
    /// serves, whether it is this repository's own).
322
    runners: Vec<(db::runners::Runner, String, bool)>,
323
    /// A registration token just made, shown once.
324
    runner_token: Option<String>,
325
    /// The repository's AI assistants and prompt (#97).
326
    assistants: super::assistants::Panel,
327
    /// The repository's Flow variables (#102).
328
    variables: super::variables::Panel,
329
    /// The repository's Flow secrets (#107).
330
    secrets: super::secrets::Panel,
331
    /// Where its data lives, when in a bucket (oxygenhub#96).
332
    storage: Option<crate::storage::Storage>,
333
    /// The coverage check's rules (#113).
334
    coverage: crate::coverage::Rules,
335
    /// Its owner's backup, when one is set up (docs/backups.md §5).
336
    backup_owner: Option<crate::backups::Setting>,
337
    /// Push protection (#268): the findings of recent refused pushes not
338
    /// yet allowed, and the fingerprints allowed.
339
    push_findings: Vec<db::push_protection::FindingRow>,
340
    secret_bypasses: Vec<db::push_protection::Bypass>,
341
    /// The history scan (#277): its progress, its open findings and those
342
    /// marked resolved, and whether the hub runs it at all.
343
    history_scan: Option<db::push_protection::ScanRow>,
344
    history_findings: Vec<db::push_protection::HistoryRow>,
345
    resolved_findings: Vec<db::push_protection::HistoryRow>,
346
    history_scan_on: bool,
347
}
348
349
impl RepoSettingsPage {
3506×
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
3516×
        crate::backups::REPO_CHOICES
3526×
    }
353
}
354
355
/// One settings page, or not found when the viewer's role does not have it.
356141×
async fn settings_page(
357141×
    app: &Shared,
358141×
    shell: Shell,
359141×
    row: db::repos::RepoRow,
360141×
    role: Role,
361141×
    section: Section,
362141×
    notice: Option<String>,
363141×
    error: Option<String>,
364141×
) -> Result<Page<RepoSettingsPage>, Error> {
365141×
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
366141×
    if !section.shown(role, storage.is_some()) {
3678×
        return Err(Error::not_found(shell));
368133×
    }
369133×
    let nav = sidebar(role, storage.is_some());
370133×
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
371133×
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
37210×
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
373123×
        None => vec![],
374
    };
375133×
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
376133×
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
377133×
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
378133×
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
379133×
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
380133×
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
381133×
    let diffusion_site = app.config.diffusion.follow.clone();
382133×
    let flow_enabled = crate::flow::enabled(app, &row);
383133×
    let site_admin = shell.is_admin;
384133×
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
385133×
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
386133×
        .await
387133×
        .map_err(|e| Error::from_db(shell.clone(), e))?
388133×
        .into_iter()
389133×
        .map(|r| {
3906×
            let own = r.scope_repo_id == Some(row.id);
3916×
            let serves = if own {
3924×
                "this repository".to_string()
3932×
            } else if r.scope_owner.is_empty() {
3942×
                "the whole site".to_string()
395
            } else {
3960×
                format!("{}'s repositories", r.scope_owner)
397
            };
3986×
            (r, serves, own)
3996×
        })
400133×
        .collect();
401133×
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
402133×
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
403133×
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
404133×
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
405133×
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
406133×
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
407133×
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
408133×
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
409133×
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
410133×
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
411133×
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
412133×
    Ok(Page(RepoSettingsPage {
413133×
        section,
414133×
        nav,
415133×
        push_findings,
416133×
        secret_bypasses,
417133×
        history_scan,
418133×
        history_findings,
419133×
        resolved_findings,
420133×
        history_scan_on,
421133×
        backup_owner,
422133×
        coverage,
423133×
        storage,
424133×
        assistants,
425133×
        variables,
426133×
        secrets,
427133×
        runners,
428133×
        runner_token: None,
429133×
        shell,
430133×
        repo,
431133×
        row,
432133×
        collaborators,
433133×
        teams,
434133×
        role,
435133×
        requirements,
436133×
        hooks,
437133×
        required_checks,
438133×
        flow_default,
439133×
        diffusion_site,
440133×
        flow_enabled,
441133×
        site_admin,
442133×
        own_runner,
443133×
        notice,
444133×
        error,
445133×
    }))
446141×
}
447
448
/// What a settings form did, carried to the page it redirects to.
449
#[derive(Deserialize, Default)]
450
pub struct Outcome {
451
    #[serde(default)]
452
    notice: Option<String>,
453
    #[serde(default)]
454
    error: Option<String>,
455
}
456
457
/// `GET /{owner}/{name}/settings`: the General page.
45812×
pub async fn settings(
45912×
    State(app): State<Shared>,
46012×
    viewer: Viewer,
46112×
    Path((owner, name)): Path<(String, String)>,
46212×
    Query(q): Query<Outcome>,
46312×
) -> Result<Page<RepoSettingsPage>, Error> {
46412×
    let path = format!("/{owner}/{name}/settings");
46512×
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
46612×
}
467
468
/// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings.
469
/// Some pages share their path with their form's `POST`, so the page is
470
/// the path's last segment.
471133×
pub async fn page(
472133×
    State(app): State<Shared>,
473133×
    viewer: Viewer,
474133×
    Path(params): Path<std::collections::HashMap<String, String>>,
475133×
    uri: Uri,
476133×
    Query(q): Query<Outcome>,
477133×
) -> Result<Page<RepoSettingsPage>, Error> {
478133×
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
479133×
    let path = format!("/{owner}/{name}/settings");
480133×
    let slug = uri.path().rsplit('/').next().unwrap_or("");
481133×
    let Some(section) = Section::parse(slug) else {
4821×
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
483
    };
484132×
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
485133×
}
486
487
/// A settings page for the repository's maintainers, or its admins: on an
488
/// archived repository, only its admins, who can bring it back.
489144×
async fn show(
490144×
    app: &Shared,
491144×
    viewer: &Viewer,
492144×
    path: &str,
493144×
    owner: &str,
494144×
    name: &str,
495144×
    section: Section,
496144×
    q: Outcome,
497144×
) -> Result<Page<RepoSettingsPage>, Error> {
498144×
    let shell = Shell::new(app, path, viewer);
499144×
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
500144×
    if role < Role::Maintain {
5015×
        return Err(Error::not_found(shell));
502139×
    }
503139×
    if row.archived && role < Role::Admin {
5040×
        return Err(super::archived(&shell));
505139×
    }
506139×
    settings_page(app, shell, row, role, section, q.notice, q.error).await
507144×
}
508
509
#[derive(Deserialize)]
510
pub struct GeneralForm {
511
    #[serde(rename = "_csrf")]
512
    csrf_token: String,
513
    description: String,
514
    default_bookmark: String,
515
    visibility: String,
516
    #[serde(default)]
517
    topics: String,
518
}
519
5203×
pub async fn update(
5213×
    State(app): State<Shared>,
5223×
    viewer: Viewer,
5233×
    Path((owner, name)): Path<(String, String)>,
5243×
    Form(f): Form<GeneralForm>,
5253×
) -> Result<Response, Error> {
5263×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
5273×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
5283×
    csrf(&viewer, &f.csrf_token, &shell)?;
5293×
    let e = |e| Error::from_db(shell.clone(), e);
5303×
    let topics = match db::repos::parse_topics(&f.topics) {
5312×
        Ok(t) => t,
5321×
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
533
    };
5342×
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
5352×
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
5362×
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
5372×
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
5380×
    }
5392×
    if role >= Role::Admin {
5402×
        if let Some(v) = Visibility::parse(&f.visibility) {
5412×
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
5420×
        }
5430×
    }
5442×
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
5452×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
5462×
    back(&row, Section::General, Some("Saved.".into()), None)
5473×
}
548
549
#[derive(Deserialize)]
550
pub struct CollaboratorForm {
551
    #[serde(rename = "_csrf")]
552
    csrf_token: String,
553
    #[serde(default)]
554
    username: String,
555
    #[serde(default)]
556
    team: String,
557
    #[serde(default)]
558
    role: String,
559
    #[serde(default)]
560
    remove: Option<String>,
561
}
562
5635×
pub async fn collaborator(
5645×
    State(app): State<Shared>,
5655×
    viewer: Viewer,
5665×
    Path((owner, name)): Path<(String, String)>,
5675×
    Form(f): Form<CollaboratorForm>,
5685×
) -> Result<Response, Error> {
5695×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
5705×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
5715×
    csrf(&viewer, &f.csrf_token, &shell)?;
5725×
    let e = |e| Error::from_db(shell.clone(), e);
5735×
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
5745×
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
5755×
            Some(a) => (Some(a.id), None, a.username),
5760×
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
577
        }
5780×
    } else if !f.team.trim().is_empty() {
5790×
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
5800×
        let team = match org {
5810×
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
5820×
            None => None,
583
        };
5840×
        match team {
5850×
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
5860×
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
587
        }
588
    } else {
5890×
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
590
    };
5915×
    if f.remove.is_some() {
5920×
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
5930×
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
5940×
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
5955×
    }
5965×
    let Some(new_role) = Role::parse(&f.role) else {
5970×
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
598
    };
5995×
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
6005×
    accounts::audit(
6015×
        &app.db,
6025×
        viewer.account(),
6035×
        "repo.collaborator.set",
6045×
        &format!("{owner}/{name}"),
6055×
        &format!("{label} {}", new_role.as_str()),
6065×
    )
6075×
    .await
6085×
    .ok();
6095×
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
6105×
}
611
612
#[derive(Deserialize)]
613
pub struct TransferForm {
614
    #[serde(rename = "_csrf")]
615
    csrf_token: String,
616
    owner: String,
617
    confirm: String,
618
}
619
620
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
621
/// user or organisation the admin may create repositories under (#108).
622
/// The old URL redirects and oxy remotes keep working.
6233×
pub async fn transfer(
6243×
    State(app): State<Shared>,
6253×
    viewer: Viewer,
6263×
    Path((owner, name)): Path<(String, String)>,
6273×
    Form(f): Form<TransferForm>,
6283×
) -> Result<Response, Error> {
6293×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
6303×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
6313×
    csrf(&viewer, &f.csrf_token, &shell)?;
6323×
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
6333×
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
6343×
    if f.confirm.trim() != repo.to_string() {
6351×
        return fail("Type the repository's full name to confirm.");
6362×
    }
6372×
    let to = f.owner.trim().trim_start_matches('@').to_string();
6382×
    let Some(dest) = RepoName::new(&to, &row.name) else {
6390×
        return fail("That is not an owner name.");
640
    };
6412×
    if dest.owner == row.owner {
6420×
        return fail("The repository is already there.");
6432×
    }
6442×
    let e = |e| Error::from_db(shell.clone(), e);
6452×
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
6462×
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
6472×
    if !known {
6481×
        return fail("There is no user or organisation of that name.");
6491×
    }
6501×
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
6510×
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
6521×
    }
6531×
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
6540×
        return fail("That owner already has a repository of this name.");
6551×
    }
656
    {
6571×
        let _guard = app.repos.exclusive_guard(&repo).await;
6581×
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
6591×
        crate::storage::renamed(&app, &repo, &dest);
6601×
        if app.wikis.exists(&repo) {
6610×
            app.wikis.rename(&repo, &dest).ok();
6621×
        }
6631×
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
6641×
        let mirror = crate::mirror::mirror_dir(&app, &repo);
6651×
        if mirror.is_dir() {
6660×
            let to = crate::mirror::mirror_dir(&app, &dest);
6670×
            if let Some(parent) = to.parent() {
6680×
                std::fs::create_dir_all(parent).ok();
6690×
            }
6700×
            std::fs::rename(&mirror, &to).ok();
6711×
        }
672
    }
6731×
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
6741×
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
6751×
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
6763×
}
677
678
#[derive(Deserialize)]
679
pub struct ArchiveForm {
680
    #[serde(rename = "_csrf")]
681
    csrf_token: String,
682
    /// `archive` or `unarchive`.
683
    action: String,
684
    #[serde(default)]
685
    reason: String,
686
}
687
688
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
689
/// back again (#108).
6903×
pub async fn archive(
6913×
    State(app): State<Shared>,
6923×
    viewer: Viewer,
6933×
    Path((owner, name)): Path<(String, String)>,
6943×
    Form(f): Form<ArchiveForm>,
6953×
) -> Result<Response, Error> {
6963×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
6973×
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
6983×
    csrf(&viewer, &f.csrf_token, &shell)?;
6993×
    let archive = match f.action.as_str() {
7003×
        "archive" => true,
7012×
        "unarchive" => false,
7021×
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
703
    };
7042×
    let reason: String = f.reason.trim().chars().take(500).collect();
7052×
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7062×
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
7072×
        .await
7082×
        .ok();
7092×
    row.archived = archive;
7102×
    row.archived_reason = if archive { reason } else { String::new() };
7112×
    let notice = if archive {
7121×
        "The repository is archived: read-only until it is brought back."
713
    } else {
7141×
        "The repository is back: writes are allowed again."
715
    };
7162×
    back(&row, Section::Advanced, Some(notice.into()), None)
7173×
}
718
719
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
7201×
pub async fn wiki_delete(
7211×
    State(app): State<Shared>,
7221×
    viewer: Viewer,
7231×
    Path((owner, name)): Path<(String, String)>,
7241×
    Form(f): Form<DeleteForm>,
7251×
) -> Result<Response, Error> {
7261×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7271×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7281×
    csrf(&viewer, &f.csrf_token, &shell)?;
7291×
    if f.confirm.trim() != repo.to_string() {
7300×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
7311×
    }
7321×
    if !crate::wiki::exists(&app, &repo) {
7330×
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
7341×
    }
7351×
    let _guard = app.wikis.exclusive_guard(&repo).await;
7361×
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
7371×
    drop(_guard);
7381×
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
7391×
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
7401×
}
741
742
#[derive(Deserialize)]
743
pub struct DeleteForm {
744
    #[serde(rename = "_csrf")]
745
    csrf_token: String,
746
    confirm: String,
747
}
748
7491×
pub async fn delete(
7501×
    State(app): State<Shared>,
7511×
    viewer: Viewer,
7521×
    Path((owner, name)): Path<(String, String)>,
7531×
    Form(f): Form<DeleteForm>,
7541×
) -> Result<Response, Error> {
7551×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7561×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7571×
    csrf(&viewer, &f.csrf_token, &shell)?;
7581×
    if f.confirm.trim() != repo.to_string() {
7590×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
7601×
    }
7611×
    let _guard = app.repos.exclusive_guard(&repo).await;
7621×
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
763
    // A repository kept in a bucket leaves its data there: the owner's.
7641×
    crate::storage::removed(&app, &repo);
7651×
    crate::wiki::trash(&app, &repo).ok();
7661×
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7671×
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7681×
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7691×
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7701×
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
7711×
    Ok(Redirect::to(&format!("/{owner}")).into_response())
7721×
}
773
774
#[derive(Deserialize)]
775
pub struct RequirementsForm {
776
    #[serde(rename = "_csrf")]
777
    csrf_token: String,
778
    #[serde(default)]
779
    required_approvals: i64,
780
    #[serde(default)]
781
    require_owners: Option<String>,
782
    #[serde(default)]
783
    allow_conflicts: Option<String>,
784
}
785
7862×
pub async fn requirements(
7872×
    State(app): State<Shared>,
7882×
    viewer: Viewer,
7892×
    Path((owner, name)): Path<(String, String)>,
7902×
    Form(f): Form<RequirementsForm>,
7912×
) -> Result<Response, Error> {
7922×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7932×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
7942×
    csrf(&viewer, &f.csrf_token, &shell)?;
7952×
    let r = db::proposals::Requirements {
7962×
        required_approvals: f.required_approvals.max(0),
7972×
        require_owners: f.require_owners.is_some(),
7982×
        allow_conflicts: f.allow_conflicts.is_some(),
7992×
    };
8002×
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8012×
    accounts::audit(
8022×
        &app.db,
8032×
        viewer.account(),
8042×
        "repo.requirements",
8052×
        &format!("{owner}/{name}"),
8062×
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
8072×
    )
8082×
    .await
8092×
    .ok();
8102×
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
8112×
}
812
8136×
pub async fn hooks(
8146×
    State(app): State<Shared>,
8156×
    viewer: Viewer,
8166×
    Path((owner, name)): Path<(String, String)>,
8176×
    Form(f): Form<super::webhooks::HookForm>,
8186×
) -> Result<Response, Error> {
8196×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8206×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8216×
    csrf(&viewer, &f.csrf_token, &shell)?;
8226×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8236×
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
8245×
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
8251×
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
826
    }
8276×
}
828
8290×
pub async fn deliveries(
8300×
    State(app): State<Shared>,
8310×
    viewer: Viewer,
8320×
    Path((owner, name, id)): Path<(String, String, i64)>,
8330×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
8340×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8350×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8360×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8370×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
8380×
}
839
8400×
pub async fn redeliver(
8410×
    State(app): State<Shared>,
8420×
    viewer: Viewer,
8430×
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
8440×
    Form(f): Form<super::session::CsrfForm>,
8450×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
8460×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8470×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8480×
    csrf(&viewer, &f.csrf, &shell)?;
8490×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8500×
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8510×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
8520×
}
853
854
#[derive(Deserialize)]
855
pub struct ChecksForm {
856
    #[serde(rename = "_csrf")]
857
    csrf_token: String,
858
    #[serde(default)]
859
    required_checks: String,
860
}
861
8622×
pub async fn required_checks(
8632×
    State(app): State<Shared>,
8642×
    viewer: Viewer,
8652×
    Path((owner, name)): Path<(String, String)>,
8662×
    Form(f): Form<ChecksForm>,
8672×
) -> Result<Response, Error> {
8682×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8692×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
8702×
    csrf(&viewer, &f.csrf_token, &shell)?;
8712×
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
8722×
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8732×
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
8742×
}
875
876
#[derive(Deserialize)]
877
pub struct CoverageForm {
878
    #[serde(rename = "_csrf")]
879
    csrf_token: String,
880
    #[serde(default)]
881
    min: String,
882
    #[serde(default)]
883
    max_drop: String,
884
}
885
886
/// Settings → Coverage (#113): the rules the `coverage` check applies.
8871×
pub async fn coverage(
8881×
    State(app): State<Shared>,
8891×
    viewer: Viewer,
8901×
    Path((owner, name)): Path<(String, String)>,
8911×
    Form(f): Form<CoverageForm>,
8921×
) -> Result<Response, Error> {
8931×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8941×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
8951×
    csrf(&viewer, &f.csrf_token, &shell)?;
8962×
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
8972×
        let v = v.trim().trim_end_matches('%').trim();
8982×
        if v.is_empty() {
8991×
            return Ok(None);
9001×
        }
9011×
        match v.parse::<f64>() {
9021×
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
9030×
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
904
        }
9052×
    };
9061×
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
9071×
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
9080×
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
909
    };
9101×
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9111×
    accounts::audit(
9121×
        &app.db,
9131×
        viewer.account(),
9141×
        "repo.coverage_rules",
9151×
        &format!("{owner}/{name}"),
9161×
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
9171×
    )
9181×
    .await
9191×
    .ok();
9201×
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
9211×
}
922
923
#[derive(Deserialize)]
924
pub struct StorageForm {
925
    #[serde(rename = "_csrf")]
926
    csrf_token: String,
927
    /// `check`, or `credentials` (with the new ones).
928
    action: String,
929
    #[serde(flatten)]
930
    bucket: BucketForm,
931
}
932
933
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
934
/// its credentials once the new ones pass the check.
9350×
pub async fn storage(
9360×
    State(app): State<Shared>,
9370×
    viewer: Viewer,
9380×
    Path((owner, name)): Path<(String, String)>,
9390×
    Form(f): Form<StorageForm>,
9400×
) -> Result<Response, Error> {
9410×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9420×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9430×
    csrf(&viewer, &f.csrf_token, &shell)?;
9440×
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
9450×
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
946
    };
9470×
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
9480×
    let outcome = match f.action.as_str() {
9490×
        "credentials" => match f.bucket.credentials(&scheme) {
9500×
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
9510×
            Err(e) => Err(format!("Credentials: {e}")),
952
        },
9530×
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
954
    };
9550×
    match outcome {
9560×
        Ok(notice) => {
9570×
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
9580×
            back(&row, Section::Storage, Some(notice), None)
959
        }
9600×
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
961
    }
9620×
}
963
964
#[derive(Deserialize)]
965
pub struct MirrorForm {
966
    #[serde(rename = "_csrf")]
967
    csrf_token: String,
968
    #[serde(default)]
969
    enabled: Option<String>,
970
}
971
972
#[derive(Deserialize)]
973
pub struct BackupForm {
974
    #[serde(rename = "_csrf")]
975
    csrf_token: String,
976
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
977
    #[serde(default)]
978
    backup: String,
979
}
980
981
/// This repository's backup (docs/backups.md §5): with its owner's, left
982
/// out, or at least hourly or daily. Its owners and admins decide.
9833×
pub async fn backup(
9843×
    State(app): State<Shared>,
9853×
    viewer: Viewer,
9863×
    Path((owner, name)): Path<(String, String)>,
9873×
    Form(f): Form<BackupForm>,
9883×
) -> Result<Response, Error> {
9893×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9903×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9913×
    csrf(&viewer, &f.csrf_token, &shell)?;
99212×
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
9931×
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
9942×
    }
9952×
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9962×
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
9972×
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
9982×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
9992×
    let notice = match f.backup.as_str() {
10002×
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
10010×
        "" => "This repository is backed up with its owner's.".to_string(),
10020×
        every => {
10030×
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1004
        }
1005
    };
10062×
    back(&row, Section::Backup, Some(notice), None)
10073×
}
1008
1009
#[derive(Deserialize)]
1010
pub struct DiffusionForm {
1011
    #[serde(rename = "_csrf")]
1012
    csrf_token: String,
1013
    /// `off`, `green`, `any`, or `default`.
1014
    diffusion: String,
1015
    /// `auto` or `propose` (absent keeps it as it is).
1016
    #[serde(default)]
1017
    land: String,
1018
}
1019
1020
/// Whether this repository follows the upstreams it pins (Diffusion,
1021
/// #148): its owners and admins decide, since it opens proposals here.
10221×
pub async fn diffusion(
10231×
    State(app): State<Shared>,
10241×
    viewer: Viewer,
10251×
    Path((owner, name)): Path<(String, String)>,
10261×
    Form(f): Form<DiffusionForm>,
10271×
) -> Result<Response, Error> {
10281×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10291×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10301×
    csrf(&viewer, &f.csrf_token, &shell)?;
10311×
    let setting = match f.diffusion.as_str() {
10321×
        "off" | "green" | "any" => f.diffusion.as_str(),
10330×
        "default" => "",
10340×
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1035
    };
10361×
    let land = match f.land.as_str() {
10371×
        "auto" | "propose" => Some(f.land.as_str()),
10380×
        "" => None,
10390×
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1040
    };
10411×
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10421×
    if let Some(l) = land {
10431×
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10440×
    }
10451×
    let what =
10461×
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
10471×
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
10481×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1049
    // Turned to land by itself: any bump already green lands now.
10501×
    if land == Some("auto") {
10510×
        let (app, row) = (app.clone(), row.clone());
10520×
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
10531×
    }
10541×
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
10550×
        let (app, row) = (app.clone(), row.clone());
10560×
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
10571×
    }
10581×
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
10591×
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1060
        crate::diffusion::Follow::Green => {
10610×
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1062
        }
10630×
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1064
    };
10651×
    back(&row, Section::Diffusion, Some(notice.into()), None)
10661×
}
1067
1068
#[derive(Deserialize)]
1069
pub struct AgentGatesForm {
1070
    #[serde(rename = "_csrf")]
1071
    csrf_token: String,
1072
    /// `on` when the box is ticked.
1073
    #[serde(default)]
1074
    agents: String,
1075
}
1076
1077
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1078
/// agent's token may approve or reject this repository's approval gates;
1079
/// for maintainers, audited.
10801×
pub async fn agent_gates(
10811×
    State(app): State<Shared>,
10821×
    viewer: Viewer,
10831×
    Path((owner, name)): Path<(String, String)>,
10841×
    Form(f): Form<AgentGatesForm>,
10851×
) -> Result<Response, Error> {
10861×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10871×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10881×
    csrf(&viewer, &f.csrf_token, &shell)?;
10891×
    let on = f.agents == "on";
10901×
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10911×
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
10921×
        .await
10931×
        .ok();
10941×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
10951×
    let notice = if on {
10961×
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1097
    } else {
10980×
        "Approval gates here are decided by people: agents' tokens are refused."
1099
    };
11001×
    back(&row, Section::Gates, Some(notice.into()), None)
11011×
}
1102
1103
#[derive(Deserialize)]
1104
pub struct PushProtectionForm {
1105
    #[serde(rename = "_csrf")]
1106
    csrf_token: String,
1107
    /// `on` when the box is ticked.
1108
    #[serde(default)]
1109
    protect: String,
1110
}
1111
1112
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1113
/// pushes adding what looks like a secret are refused; for maintainers,
1114
/// audited.
11154×
pub async fn push_protection(
11164×
    State(app): State<Shared>,
11174×
    viewer: Viewer,
11184×
    Path((owner, name)): Path<(String, String)>,
11194×
    Form(f): Form<PushProtectionForm>,
11204×
) -> Result<Response, Error> {
11214×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11224×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11234×
    csrf(&viewer, &f.csrf_token, &shell)?;
11244×
    let on = f.protect == "on";
11254×
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11264×
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
11274×
        .await
11284×
        .ok();
11294×
    if on {
11302×
        // What was pushed while it was off is scanned now (#277).
11312×
        crate::history_scan::wake(&app, row.id);
11322×
    }
11334×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
11344×
    let notice = if on {
11352×
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1136
    } else {
11372×
        "Push protection is off: pushes are no longer scanned for secrets here."
1138
    };
11394×
    back(&row, Section::PushProtection, Some(notice.into()), None)
11404×
}
1141
1142
#[derive(Deserialize)]
1143
pub struct SecretBypassForm {
1144
    #[serde(rename = "_csrf")]
1145
    csrf_token: String,
1146
    fingerprint: String,
1147
    #[serde(default)]
1148
    reason: String,
1149
}
1150
1151
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1152
/// push protection finding through, by its fingerprint, with a reason; for
1153
/// maintainers, audited as `secret.bypass`.
11545×
pub async fn secret_bypass(
11555×
    State(app): State<Shared>,
11565×
    viewer: Viewer,
11575×
    Path((owner, name)): Path<(String, String)>,
11585×
    Form(f): Form<SecretBypassForm>,
11595×
) -> Result<Response, Error> {
11605×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11615×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11624×
    csrf(&viewer, &f.csrf_token, &shell)?;
11634×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
11644×
    let reason = f.reason.trim();
116564×
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
11660×
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
11674×
    } else if reason.is_empty() {
11681×
        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.")
11693×
    } else if reason.chars().count() > 500 {
11700×
        Some("Keep the reason under 500 characters.")
1171
    } else {
11723×
        None
1173
    };
11744×
    if let Some(e) = error {
11751×
        return back(&row, Section::PushProtection, None, Some(e.into()));
11763×
    }
11773×
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
11783×
        .await
11793×
        .map_err(|e| Error::from_db(shell.clone(), e))?
11803×
        .unwrap_or_default();
11813×
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
11823×
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11833×
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
11843×
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
11853×
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
11863×
    back(&row, Section::PushProtection, Some(notice), None)
11875×
}
1188
1189
#[derive(Deserialize)]
1190
pub struct BypassRemoveForm {
1191
    #[serde(rename = "_csrf")]
1192
    csrf_token: String,
1193
    fingerprint: String,
1194
}
1195
1196
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1197
/// take back an allowed fingerprint, so a push adding that value is refused
1198
/// again; for maintainers, audited as `secret.bypass_removed`.
11993×
pub async fn secret_bypass_remove(
12003×
    State(app): State<Shared>,
12013×
    viewer: Viewer,
12023×
    Path((owner, name)): Path<(String, String)>,
12033×
    Form(f): Form<BypassRemoveForm>,
12043×
) -> Result<Response, Error> {
12053×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12063×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12072×
    csrf(&viewer, &f.csrf_token, &shell)?;
12082×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
12092×
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12102×
    let Some(b) = removed else {
12111×
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1212
    };
12131×
    let detail = if b.rule.is_empty() {
12140×
        format!("{fingerprint} (was: {})", b.reason)
1215
    } else {
12161×
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1217
    };
12181×
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
12191×
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
12201×
    back(&row, Section::PushProtection, Some(notice), None)
12213×
}
1222
1223
#[derive(Deserialize)]
1224
pub struct SecretResolveForm {
1225
    #[serde(rename = "_csrf")]
1226
    csrf_token: String,
1227
    finding: i64,
1228
    #[serde(default)]
1229
    reason: String,
1230
}
1231
1232
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1233
/// finding of the history scan resolved (the secret was revoked or
1234
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
12354×
pub async fn secret_resolve(
12364×
    State(app): State<Shared>,
12374×
    viewer: Viewer,
12384×
    Path((owner, name)): Path<(String, String)>,
12394×
    Form(f): Form<SecretResolveForm>,
12404×
) -> Result<Response, Error> {
12414×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12424×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12433×
    csrf(&viewer, &f.csrf_token, &shell)?;
12443×
    let reason = f.reason.trim();
12453×
    let error = if reason.is_empty() {
12461×
        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.")
12472×
    } else if reason.chars().count() > 500 {
12480×
        Some("Keep the reason under 500 characters.")
1249
    } else {
12502×
        None
1251
    };
12523×
    if let Some(e) = error {
12531×
        return back(&row, Section::PushProtection, None, Some(e.into()));
12542×
    }
12552×
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
12562×
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12572×
    let Some(found) = done else {
12581×
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1259
    };
12601×
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
12611×
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
12621×
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
12631×
    back(&row, Section::PushProtection, Some(notice), None)
12644×
}
1265
1266
#[derive(Deserialize)]
1267
pub struct SecretScanForm {
1268
    #[serde(rename = "_csrf")]
1269
    csrf_token: String,
1270
}
1271
1272
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1273
/// whole history again now, with the current rules; for maintainers,
1274
/// audited as `secret.history_scan`.
12752×
pub async fn secret_scan(
12762×
    State(app): State<Shared>,
12772×
    viewer: Viewer,
12782×
    Path((owner, name)): Path<(String, String)>,
12792×
    Form(f): Form<SecretScanForm>,
12802×
) -> Result<Response, Error> {
12812×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12822×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12831×
    csrf(&viewer, &f.csrf_token, &shell)?;
12841×
    if !row.push_protection {
12850×
        return back(
12860×
            &row,
12870×
            Section::PushProtection,
12880×
            None,
12890×
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1290
        );
12911×
    }
12921×
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12931×
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
12941×
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
12951×
    back(&row, Section::PushProtection, Some(notice.into()), None)
12962×
}
1297
1298
#[derive(Deserialize)]
1299
pub struct SupersedeForm {
1300
    #[serde(rename = "_csrf")]
1301
    csrf_token: String,
1302
    /// `on` when the box is ticked: superseded runs already running are
1303
    /// cancelled too.
1304
    #[serde(default)]
1305
    running: String,
1306
}
1307
1308
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1309
/// a new push or proposal run cancels the older runs it supersedes that are
1310
/// already running, or only those still queued; for maintainers, audited.
13111×
pub async fn flow_supersede(
13121×
    State(app): State<Shared>,
13131×
    viewer: Viewer,
13141×
    Path((owner, name)): Path<(String, String)>,
13151×
    Form(f): Form<SupersedeForm>,
13161×
) -> Result<Response, Error> {
13171×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13181×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13191×
    csrf(&viewer, &f.csrf_token, &shell)?;
13201×
    let keep = f.running != "on";
13211×
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13221×
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
13231×
        .await
13241×
        .ok();
13251×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
13261×
    let notice = if keep {
13270×
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1328
    } else {
13291×
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1330
    };
13311×
    back(&row, Section::Runs, Some(notice.into()), None)
13321×
}
1333
1334
#[derive(Deserialize)]
1335
pub struct ReuseForm {
1336
    #[serde(rename = "_csrf")]
1337
    csrf_token: String,
1338
    /// `on` when the box is ticked.
1339
    #[serde(default)]
1340
    reuse: String,
1341
}
1342
1343
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1344
/// push run reuses a job's pass on the same state; for maintainers,
1345
/// audited.
13460×
pub async fn flow_reuse(
13470×
    State(app): State<Shared>,
13480×
    viewer: Viewer,
13490×
    Path((owner, name)): Path<(String, String)>,
13500×
    Form(f): Form<ReuseForm>,
13510×
) -> Result<Response, Error> {
13520×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13530×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13540×
    csrf(&viewer, &f.csrf_token, &shell)?;
13550×
    let on = f.reuse == "on";
13560×
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13570×
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
13580×
    let notice = if on {
13590×
        "A push run now reuses a job's pass on the same state."
1360
    } else {
13610×
        "Every job of every push now runs, passed before or not."
1362
    };
13630×
    back(&row, Section::Runs, Some(notice.into()), None)
13640×
}
1365
1366
#[derive(Deserialize)]
1367
pub struct SkipForm {
1368
    #[serde(rename = "_csrf")]
1369
    csrf_token: String,
1370
    /// `any`, `others` or `never`.
1371
    skip: String,
1372
}
1373
1374
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1375
/// may skip its run with `[skip flow]` in its message; for maintainers,
1376
/// audited.
13772×
pub async fn flow_skip(
13782×
    State(app): State<Shared>,
13792×
    viewer: Viewer,
13802×
    Path((owner, name)): Path<(String, String)>,
13812×
    Form(f): Form<SkipForm>,
13822×
) -> Result<Response, Error> {
13832×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13842×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13852×
    csrf(&viewer, &f.csrf_token, &shell)?;
13862×
    let notice = match f.skip.as_str() {
13872×
        "any" => "A push may now skip its run on any bookmark.".to_string(),
13882×
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
13891×
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
13901×
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1391
    };
13921×
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13931×
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
13941×
    back(&row, Section::Runs, Some(notice), None)
13952×
}
1396
1397
#[derive(Deserialize)]
1398
pub struct PriorityForm {
1399
    #[serde(rename = "_csrf")]
1400
    csrf_token: String,
1401
    /// `low`, `normal`, `high`, or `default`.
1402
    priority: String,
1403
}
1404
1405
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1406
/// level. Maintainers choose low, normal or the default; only site
1407
/// administrators raise a repository to high.
14082×
pub async fn flow_priority(
14092×
    State(app): State<Shared>,
14102×
    viewer: Viewer,
14112×
    Path((owner, name)): Path<(String, String)>,
14122×
    Form(f): Form<PriorityForm>,
14132×
) -> Result<Response, Error> {
14142×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14152×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14162×
    csrf(&viewer, &f.csrf_token, &shell)?;
14172×
    let level = match f.priority.as_str() {
14182×
        "low" | "normal" | "high" => f.priority.as_str(),
14190×
        "default" => "",
14200×
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1421
    };
14222×
    if level == "high" && !viewer.is_admin() {
14230×
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
14242×
    }
14252×
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14262×
    accounts::audit(
14272×
        &app.db,
14282×
        viewer.account(),
14292×
        "repo.flow_priority",
14302×
        &format!("{owner}/{name}"),
14312×
        if level.is_empty() { "default" } else { level },
1432
    )
14332×
    .await
14342×
    .ok();
14352×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
14362×
    let notice = if level.is_empty() {
14370×
        "Its jobs follow the organisation's and the site's priority.".to_string()
1438
    } else {
14392×
        format!("Its jobs are {level} priority.")
1440
    };
14412×
    back(&row, Section::Runs, Some(notice), None)
14422×
}
1443
1444
#[derive(Deserialize)]
1445
pub struct FlowForm {
1446
    #[serde(rename = "_csrf")]
1447
    csrf_token: String,
1448
    /// `on`, `off`, or `default`.
1449
    flow: String,
1450
}
1451
1452
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1453
/// anything; the repository's maintainers may turn it on on their own
1454
/// runners (`own`), when the repository or its owner has one, or off (#225).
1455
/// Only site administrators let the site's runners take its jobs.
14569×
pub async fn flow(
14579×
    State(app): State<Shared>,
14589×
    viewer: Viewer,
14599×
    Path((owner, name)): Path<(String, String)>,
14609×
    Form(f): Form<FlowForm>,
14619×
) -> Result<Response, Error> {
14629×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14639×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14649×
    csrf(&viewer, &f.csrf_token, &shell)?;
14659×
    let setting = match f.flow.as_str() {
14669×
        "on" => "on",
14676×
        "own" => "own",
14684×
        "off" => "off",
14693×
        "default" => "",
14702×
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1471
    };
14727×
    if !viewer.is_admin() {
14734×
        if matches!(setting, "on" | "") {
14742×
            return back(
14752×
                &row,
14762×
                Section::Pipelines,
14772×
                None,
14782×
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1479
            );
14802×
        }
14812×
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
14821×
            return back(
14831×
                &row,
14841×
                Section::Pipelines,
14851×
                None,
14861×
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1487
            );
14881×
        }
14893×
    }
14904×
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14914×
    let label = if setting.is_empty() { "default" } else { setting };
14924×
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
14934×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
14944×
    let notice = if row.flow == "own" {
14951×
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
14963×
    } else if crate::flow::enabled(&app, &row) {
14971×
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1498
    } else {
14992×
        "OxygenFlow is off for this repository."
1500
    };
15014×
    back(&row, Section::Pipelines, Some(notice.into()), None)
15029×
}
1503
15042×
pub async fn mirror(
15052×
    State(app): State<Shared>,
15062×
    viewer: Viewer,
15072×
    Path((owner, name)): Path<(String, String)>,
15082×
    Form(f): Form<MirrorForm>,
15092×
) -> Result<Response, Error> {
15102×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15112×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15122×
    csrf(&viewer, &f.csrf_token, &shell)?;
15132×
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15142×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15152×
    if row.git_mirror {
15161×
        crate::mirror::refresh(&app, &row).await.ok();
15171×
    }
15182×
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
15192×
        .await
15202×
        .ok();
15212×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15222×
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
15232×
    back(&row, Section::GitMirror, Some(notice.into()), None)
15242×
}
1525
1526
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1527
/// token for a runner that serves this repository only (oxygenhub#41).
15282×
pub async fn runner_token(
15292×
    State(app): State<Shared>,
15302×
    viewer: Viewer,
15312×
    Path((owner, name)): Path<(String, String)>,
15322×
    Form(f): Form<super::session::CsrfForm>,
15332×
) -> Result<Page<RepoSettingsPage>, Error> {
15342×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15352×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
15362×
    csrf(&viewer, &f.csrf, &shell)?;
15372×
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
15382×
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
15392×
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
15402×
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
15412×
        .await
15422×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
15432×
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
15442×
    let mut page =
15452×
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
15462×
    page.0.runner_token = Some(token);
15472×
    Ok(page)
15482×
}
1549
1550
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1551
/// repository's own runners, when it is running nothing.
15522×
pub async fn runner_remove(
15532×
    State(app): State<Shared>,
15542×
    viewer: Viewer,
15552×
    Path((owner, name, id)): Path<(String, String, i64)>,
15562×
    Form(f): Form<super::session::CsrfForm>,
15572×
) -> Result<Response, Error> {
15582×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15592×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
15602×
    csrf(&viewer, &f.csrf, &shell)?;
15612×
    let e = |e| Error::from_db(shell.clone(), e);
15622×
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
15631×
        return Err(Error::not_found(shell));
1564
    };
15651×
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
15660×
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
15671×
    }
15681×
    db::runners::remove(&app.db, id).await.map_err(e)?;
15691×
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
15701×
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
15712×
}
1572
1573404×
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1574404×
    crate::levels::At::repo(row)
1575404×
}
1576
1577
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1578
/// and prompt (#97), for its admins.
15792×
pub async fn assistants(
15802×
    State(app): State<Shared>,
15812×
    viewer: Viewer,
15822×
    Path((owner, name)): Path<(String, String)>,
15832×
    Form(f): Form<super::assistants::AssistantForm>,
15842×
) -> Result<Response, Error> {
15852×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15862×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
15871×
    csrf(&viewer, &f.csrf_token, &shell)?;
15881×
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
15891×
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
15900×
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1591
    }
15922×
}
1593
1594
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1595
/// variables (#102), for its admins.
15963×
pub async fn variables(
15973×
    State(app): State<Shared>,
15983×
    viewer: Viewer,
15993×
    Path((owner, name)): Path<(String, String)>,
16003×
    Form(f): Form<super::variables::VariableForm>,
16013×
) -> Result<Response, Error> {
16023×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16033×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16043×
    csrf(&viewer, &f.csrf_token, &shell)?;
16053×
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16062×
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
16071×
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1608
    }
16093×
}
1610
1611
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1612
/// (#107), for its admins.
16131×
pub async fn secrets(
16141×
    State(app): State<Shared>,
16151×
    viewer: Viewer,
16161×
    Path((owner, name)): Path<(String, String)>,
16171×
    Form(f): Form<super::secrets::SecretForm>,
16181×
) -> Result<Response, Error> {
16191×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16201×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16211×
    csrf(&viewer, &f.csrf_token, &shell)?;
16221×
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16231×
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
16240×
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1625
    }
16261×
}
1627
1628
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
16290×
pub async fn assistant_models(
16300×
    State(app): State<Shared>,
16310×
    viewer: Viewer,
16320×
    Path((owner, name)): Path<(String, String)>,
16330×
    Form(f): Form<super::assistants::ModelsForm>,
16340×
) -> Result<axum::Json<serde_json::Value>, Error> {
16350×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16360×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16370×
    csrf(&viewer, &f.csrf_token, &shell)?;
16380×
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
16390×
}
1640
1641
/// Whether a runner serves this repository that is not the site's: one
1642
/// scoped to it or to its owner (#225).
1643135×
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1644135×
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1645135×
}