OXYGENHUB
# 155cab142b63 oxygenhub / crates / hub / src / web / repo_settings.rs
32.4 KiB · Rust · last changed Adding an assistant: provider presets and the endpoint's own model list 1 month ago source blame history raw
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, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use hub_api::Visibility;
13
use serde::Deserialize;
14
15
#[derive(Template)]
16
#[template(path = "repo_new.html")]
17
pub struct NewRepoPage {
18
    shell: Shell,
19
    owners: Vec<String>,
20
    error: Option<String>,
21
}
22
23
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
24
    let mut owners = vec![me.username.clone()];
25
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
26
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
27
    }
28
    owners
29
}
30
31
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
32
    let shell = Shell::new(&app, "/new", &viewer);
33
    let me = require(&viewer, &shell)?;
34
    let owners = owners_for(&app, &me.account).await;
35
    Ok(Page(NewRepoPage { shell, owners, error: None }))
36
}
37
38
#[derive(Deserialize)]
39
pub struct NewRepoForm {
40
    #[serde(rename = "_csrf")]
41
    csrf_token: String,
42
    owner: String,
43
    name: String,
44
    #[serde(default)]
45
    description: String,
46
    #[serde(default = "default_visibility")]
47
    visibility: String,
48
}
49
50
fn default_visibility() -> String {
51
    "public".into()
52
}
53
54
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
55
    let shell = Shell::new(&app, "/new", &viewer);
56
    let me = require(&viewer, &shell)?;
57
    csrf(&viewer, &f.csrf_token, &shell)?;
58
    let owners = owners_for(&app, &me.account).await;
59
    let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response();
60
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
61
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
62
    };
63
    if name.name.ends_with(crate::wiki::SUFFIX) {
64
        return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki."));
65
    }
66
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
67
        return Ok(fail("You cannot create repositories under that owner."));
68
    }
69
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
70
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
71
        || app.repos.exists(&name)
72
    {
73
        return Ok(fail("That repository exists."));
74
    }
75
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
76
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
77
        .await
78
        .map_err(|e| Error::from_db(shell.clone(), e))?;
79
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
80
    Ok(Redirect::to(&name.url()).into_response())
81
}
82
83
#[derive(Template)]
84
#[template(path = "repo_settings.html")]
85
pub struct RepoSettingsPage {
86
    shell: Shell,
87
    repo: RepoShell,
88
    row: db::repos::RepoRow,
89
    collaborators: Vec<accounts::Collaborator>,
90
    teams: Vec<accounts::Team>,
91
    role: Role,
92
    requirements: db::proposals::Requirements,
93
    hooks: Vec<super::webhooks::HookRow>,
94
    required_checks: String,
95
    /// Whether hub.toml alone would run Flow here, and whether it runs.
96
    flow_default: bool,
97
    flow_enabled: bool,
98
    site_admin: bool,
99
    notice: Option<String>,
100
    error: Option<String>,
101
    /// Runners that may take this repository's jobs: (runner, what it
102
    /// serves, whether it is this repository's own).
103
    runners: Vec<(db::runners::Runner, String, bool)>,
104
    /// A registration token just made, shown once.
105
    runner_token: Option<String>,
106
    /// The repository's AI assistants and prompt (#97).
107
    assistants: super::assistants::Panel,
108
    /// The repository's Flow variables (#102).
109
    variables: super::variables::Panel,
110
    /// The repository's Flow secrets (#107).
111
    secrets: super::secrets::Panel,
112
}
113
114
async fn settings_page(
115
    app: &Shared,
116
    shell: Shell,
117
    row: db::repos::RepoRow,
118
    role: Role,
119
    notice: Option<String>,
120
    error: Option<String>,
121
) -> Result<Page<RepoSettingsPage>, Error> {
122
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
123
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
124
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
125
        None => vec![],
126
    };
127
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
128
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
129
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
130
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
131
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
132
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
133
    let flow_enabled = crate::flow::enabled(app, &row);
134
    let site_admin = shell.is_admin;
135
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
136
        .await
137
        .map_err(|e| Error::from_db(shell.clone(), e))?
138
        .into_iter()
139
        .map(|r| {
140
            let own = r.scope_repo_id == Some(row.id);
141
            let serves = if own {
142
                "this repository".to_string()
143
            } else if r.scope_owner.is_empty() {
144
                "the whole site".to_string()
145
            } else {
146
                format!("{}'s repositories", r.scope_owner)
147
            };
148
            (r, serves, own)
149
        })
150
        .collect();
151
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
152
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
153
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
154
    Ok(Page(RepoSettingsPage {
155
        assistants,
156
        variables,
157
        secrets,
158
        runners,
159
        runner_token: None,
160
        shell,
161
        repo,
162
        row,
163
        collaborators,
164
        teams,
165
        role,
166
        requirements,
167
        hooks,
168
        required_checks,
169
        flow_default,
170
        flow_enabled,
171
        site_admin,
172
        notice,
173
        error,
174
    }))
175
}
176
177
pub async fn settings(
178
    State(app): State<Shared>,
179
    viewer: Viewer,
180
    Path((owner, name)): Path<(String, String)>,
181
) -> Result<Page<RepoSettingsPage>, Error> {
182
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
183
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
184
    settings_page(&app, shell, row, role, None, None).await
185
}
186
187
#[derive(Deserialize)]
188
pub struct GeneralForm {
189
    #[serde(rename = "_csrf")]
190
    csrf_token: String,
191
    description: String,
192
    default_bookmark: String,
193
    visibility: String,
194
    #[serde(default)]
195
    topics: String,
196
}
197
198
pub async fn update(
199
    State(app): State<Shared>,
200
    viewer: Viewer,
201
    Path((owner, name)): Path<(String, String)>,
202
    Form(f): Form<GeneralForm>,
203
) -> Result<Page<RepoSettingsPage>, Error> {
204
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
205
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
206
    csrf(&viewer, &f.csrf_token, &shell)?;
207
    let e = |e| Error::from_db(shell.clone(), e);
208
    let topics = match db::repos::parse_topics(&f.topics) {
209
        Ok(t) => t,
210
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
211
    };
212
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
213
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
214
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
215
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
216
    }
217
    if role >= Role::Admin {
218
        if let Some(v) = Visibility::parse(&f.visibility) {
219
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
220
        }
221
    }
222
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
223
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
224
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
225
}
226
227
#[derive(Deserialize)]
228
pub struct CollaboratorForm {
229
    #[serde(rename = "_csrf")]
230
    csrf_token: String,
231
    #[serde(default)]
232
    username: String,
233
    #[serde(default)]
234
    team: String,
235
    #[serde(default)]
236
    role: String,
237
    #[serde(default)]
238
    remove: Option<String>,
239
}
240
241
pub async fn collaborator(
242
    State(app): State<Shared>,
243
    viewer: Viewer,
244
    Path((owner, name)): Path<(String, String)>,
245
    Form(f): Form<CollaboratorForm>,
246
) -> Result<Page<RepoSettingsPage>, Error> {
247
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
248
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
249
    csrf(&viewer, &f.csrf_token, &shell)?;
250
    let e = |e| Error::from_db(shell.clone(), e);
251
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
252
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
253
            Some(a) => (Some(a.id), None, a.username),
254
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
255
        }
256
    } else if !f.team.trim().is_empty() {
257
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
258
        let team = match org {
259
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
260
            None => None,
261
        };
262
        match team {
263
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
264
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
265
        }
266
    } else {
267
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
268
    };
269
    if f.remove.is_some() {
270
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
271
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
272
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
273
    }
274
    let Some(new_role) = Role::parse(&f.role) else {
275
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
276
    };
277
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
278
    accounts::audit(
279
        &app.db,
280
        viewer.account(),
281
        "repo.collaborator.set",
282
        &format!("{owner}/{name}"),
283
        &format!("{label} {}", new_role.as_str()),
284
    )
285
    .await
286
    .ok();
287
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
288
}
289
290
#[derive(Deserialize)]
291
pub struct TransferForm {
292
    #[serde(rename = "_csrf")]
293
    csrf_token: String,
294
    owner: String,
295
    confirm: String,
296
}
297
298
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
299
/// user or organisation the admin may create repositories under (#108).
300
/// The old URL redirects and oxy remotes keep working.
301
pub async fn transfer(
302
    State(app): State<Shared>,
303
    viewer: Viewer,
304
    Path((owner, name)): Path<(String, String)>,
305
    Form(f): Form<TransferForm>,
306
) -> Result<Response, Error> {
307
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
308
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
309
    csrf(&viewer, &f.csrf_token, &shell)?;
310
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
311
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
312
    if f.confirm.trim() != repo.to_string() {
313
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
314
    }
315
    let to = f.owner.trim().trim_start_matches('@').to_string();
316
    let Some(dest) = RepoName::new(&to, &row.name) else {
317
        return Ok(fail("That is not an owner name.").await?.into_response());
318
    };
319
    if dest.owner == row.owner {
320
        return Ok(fail("The repository is already there.").await?.into_response());
321
    }
322
    let e = |e| Error::from_db(shell.clone(), e);
323
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
324
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
325
    if !known {
326
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
327
    }
328
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
329
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
330
    }
331
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
332
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
333
    }
334
    {
335
        let _guard = app.repos.write_guard(&repo).await;
336
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
337
        if app.wikis.exists(&repo) {
338
            app.wikis.rename(&repo, &dest).ok();
339
        }
340
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
341
        let mirror = crate::mirror::mirror_dir(&app, &repo);
342
        if mirror.is_dir() {
343
            let to = crate::mirror::mirror_dir(&app, &dest);
344
            if let Some(parent) = to.parent() {
345
                std::fs::create_dir_all(parent).ok();
346
            }
347
            std::fs::rename(&mirror, &to).ok();
348
        }
349
    }
350
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
351
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
352
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
353
}
354
355
#[derive(Deserialize)]
356
pub struct ArchiveForm {
357
    #[serde(rename = "_csrf")]
358
    csrf_token: String,
359
    /// `archive` or `unarchive`.
360
    action: String,
361
    #[serde(default)]
362
    reason: String,
363
}
364
365
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
366
/// back again (#108).
367
pub async fn archive(
368
    State(app): State<Shared>,
369
    viewer: Viewer,
370
    Path((owner, name)): Path<(String, String)>,
371
    Form(f): Form<ArchiveForm>,
372
) -> Result<Page<RepoSettingsPage>, Error> {
373
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
374
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
375
    csrf(&viewer, &f.csrf_token, &shell)?;
376
    let archive = match f.action.as_str() {
377
        "archive" => true,
378
        "unarchive" => false,
379
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
380
    };
381
    let reason: String = f.reason.trim().chars().take(500).collect();
382
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
383
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
384
        .await
385
        .ok();
386
    row.archived = archive;
387
    row.archived_reason = if archive { reason } else { String::new() };
388
    let notice = if archive {
389
        "The repository is archived: read-only until it is brought back."
390
    } else {
391
        "The repository is back: writes are allowed again."
392
    };
393
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
394
}
395
396
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
397
pub async fn wiki_delete(
398
    State(app): State<Shared>,
399
    viewer: Viewer,
400
    Path((owner, name)): Path<(String, String)>,
401
    Form(f): Form<DeleteForm>,
402
) -> Result<Page<RepoSettingsPage>, Error> {
403
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
404
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
405
    csrf(&viewer, &f.csrf_token, &shell)?;
406
    if f.confirm.trim() != repo.to_string() {
407
        return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await;
408
    }
409
    if !crate::wiki::exists(&app, &repo) {
410
        return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await;
411
    }
412
    let _guard = app.wikis.write_guard(&repo).await;
413
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
414
    drop(_guard);
415
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
416
    settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await
417
}
418
419
#[derive(Deserialize)]
420
pub struct DeleteForm {
421
    #[serde(rename = "_csrf")]
422
    csrf_token: String,
423
    confirm: String,
424
}
425
426
pub async fn delete(
427
    State(app): State<Shared>,
428
    viewer: Viewer,
429
    Path((owner, name)): Path<(String, String)>,
430
    Form(f): Form<DeleteForm>,
431
) -> Result<Response, Error> {
432
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
433
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
434
    csrf(&viewer, &f.csrf_token, &shell)?;
435
    if f.confirm.trim() != repo.to_string() {
436
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
437
            .await?
438
            .into_response());
439
    }
440
    let _guard = app.repos.write_guard(&repo).await;
441
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
442
    crate::wiki::trash(&app, &repo).ok();
443
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
444
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
445
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
446
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
447
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
448
    Ok(Redirect::to(&format!("/{owner}")).into_response())
449
}
450
451
#[derive(Deserialize)]
452
pub struct RequirementsForm {
453
    #[serde(rename = "_csrf")]
454
    csrf_token: String,
455
    #[serde(default)]
456
    required_approvals: i64,
457
    #[serde(default)]
458
    require_owners: Option<String>,
459
    #[serde(default)]
460
    allow_conflicts: Option<String>,
461
}
462
463
pub async fn requirements(
464
    State(app): State<Shared>,
465
    viewer: Viewer,
466
    Path((owner, name)): Path<(String, String)>,
467
    Form(f): Form<RequirementsForm>,
468
) -> Result<Page<RepoSettingsPage>, Error> {
469
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
470
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
471
    csrf(&viewer, &f.csrf_token, &shell)?;
472
    let r = db::proposals::Requirements {
473
        required_approvals: f.required_approvals.max(0),
474
        require_owners: f.require_owners.is_some(),
475
        allow_conflicts: f.allow_conflicts.is_some(),
476
    };
477
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
478
    accounts::audit(
479
        &app.db,
480
        viewer.account(),
481
        "repo.requirements",
482
        &format!("{owner}/{name}"),
483
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
484
    )
485
    .await
486
    .ok();
487
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
488
}
489
490
pub async fn hooks(
491
    State(app): State<Shared>,
492
    viewer: Viewer,
493
    Path((owner, name)): Path<(String, String)>,
494
    Form(f): Form<super::webhooks::HookForm>,
495
) -> Result<Page<RepoSettingsPage>, Error> {
496
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
497
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
498
    csrf(&viewer, &f.csrf_token, &shell)?;
499
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
500
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
501
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
502
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
503
    }
504
}
505
506
pub async fn deliveries(
507
    State(app): State<Shared>,
508
    viewer: Viewer,
509
    Path((owner, name, id)): Path<(String, String, i64)>,
510
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
511
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
512
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
513
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
514
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
515
}
516
517
pub async fn redeliver(
518
    State(app): State<Shared>,
519
    viewer: Viewer,
520
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
521
    Form(f): Form<super::session::CsrfForm>,
522
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
523
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
524
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
525
    csrf(&viewer, &f.csrf, &shell)?;
526
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
527
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
528
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
529
}
530
531
#[derive(Deserialize)]
532
pub struct ChecksForm {
533
    #[serde(rename = "_csrf")]
534
    csrf_token: String,
535
    #[serde(default)]
536
    required_checks: String,
537
}
538
539
pub async fn required_checks(
540
    State(app): State<Shared>,
541
    viewer: Viewer,
542
    Path((owner, name)): Path<(String, String)>,
543
    Form(f): Form<ChecksForm>,
544
) -> Result<Page<RepoSettingsPage>, Error> {
545
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
546
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
547
    csrf(&viewer, &f.csrf_token, &shell)?;
548
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
549
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
551
}
552
553
#[derive(Deserialize)]
554
pub struct MirrorForm {
555
    #[serde(rename = "_csrf")]
556
    csrf_token: String,
557
    #[serde(default)]
558
    enabled: Option<String>,
559
}
560
561
#[derive(Deserialize)]
562
pub struct FlowForm {
563
    #[serde(rename = "_csrf")]
564
    csrf_token: String,
565
    /// `on`, `off`, or `default`.
566
    flow: String,
567
}
568
569
/// Turn OxygenFlow on or off for one repository. Runs use the site's
570
/// runners, so only site administrators decide.
571
pub async fn flow(
572
    State(app): State<Shared>,
573
    viewer: Viewer,
574
    Path((owner, name)): Path<(String, String)>,
575
    Form(f): Form<FlowForm>,
576
) -> Result<Page<RepoSettingsPage>, Error> {
577
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
578
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
579
    csrf(&viewer, &f.csrf_token, &shell)?;
580
    if !viewer.is_admin() {
581
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
582
    }
583
    let setting = match f.flow.as_str() {
584
        "on" => "on",
585
        "off" => "off",
586
        "default" => "",
587
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
588
    };
589
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
590
    let label = if setting.is_empty() { "default" } else { setting };
591
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
592
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
593
    let notice = if crate::flow::enabled(&app, &row) {
594
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
595
    } else {
596
        "OxygenFlow is off for this repository."
597
    };
598
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
599
}
600
601
pub async fn mirror(
602
    State(app): State<Shared>,
603
    viewer: Viewer,
604
    Path((owner, name)): Path<(String, String)>,
605
    Form(f): Form<MirrorForm>,
606
) -> Result<Page<RepoSettingsPage>, Error> {
607
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
608
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
609
    csrf(&viewer, &f.csrf_token, &shell)?;
610
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
611
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
612
    if row.git_mirror {
613
        crate::mirror::refresh(&app, &row).await;
614
    }
615
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
616
        .await
617
        .ok();
618
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
619
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
620
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
621
}
622
623
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
624
/// token for a runner that serves this repository only (oxygenhub#41).
625
pub async fn runner_token(
626
    State(app): State<Shared>,
627
    viewer: Viewer,
628
    Path((owner, name)): Path<(String, String)>,
629
    Form(f): Form<super::session::CsrfForm>,
630
) -> Result<Page<RepoSettingsPage>, Error> {
631
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
632
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
633
    csrf(&viewer, &f.csrf, &shell)?;
634
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
635
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
636
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
637
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
638
        .await
639
        .map_err(|e| Error::from_db(shell.clone(), e))?;
640
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
641
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
642
    page.0.runner_token = Some(token);
643
    Ok(page)
644
}
645
646
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
647
/// repository's own runners, when it is running nothing.
648
pub async fn runner_remove(
649
    State(app): State<Shared>,
650
    viewer: Viewer,
651
    Path((owner, name, id)): Path<(String, String, i64)>,
652
    Form(f): Form<super::session::CsrfForm>,
653
) -> Result<Page<RepoSettingsPage>, Error> {
654
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
655
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
656
    csrf(&viewer, &f.csrf, &shell)?;
657
    let e = |e| Error::from_db(shell.clone(), e);
658
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
659
        return Err(Error::not_found(shell));
660
    };
661
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
662
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
663
    }
664
    db::runners::remove(&app.db, id).await.map_err(e)?;
665
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
666
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
667
}
668
669
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
670
    crate::levels::At::repo(row)
671
}
672
673
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
674
/// and prompt (#97), for its admins.
675
pub async fn assistants(
676
    State(app): State<Shared>,
677
    viewer: Viewer,
678
    Path((owner, name)): Path<(String, String)>,
679
    Form(f): Form<super::assistants::AssistantForm>,
680
) -> Result<Page<RepoSettingsPage>, Error> {
681
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
682
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
683
    csrf(&viewer, &f.csrf_token, &shell)?;
684
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
685
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
686
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
687
    }
688
}
689
690
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
691
/// variables (#102), for its admins.
692
pub async fn variables(
693
    State(app): State<Shared>,
694
    viewer: Viewer,
695
    Path((owner, name)): Path<(String, String)>,
696
    Form(f): Form<super::variables::VariableForm>,
697
) -> Result<Page<RepoSettingsPage>, Error> {
698
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
699
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
700
    csrf(&viewer, &f.csrf_token, &shell)?;
701
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
702
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
703
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
704
    }
705
}
706
707
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
708
/// (#107), for its admins.
709
pub async fn secrets(
710
    State(app): State<Shared>,
711
    viewer: Viewer,
712
    Path((owner, name)): Path<(String, String)>,
713
    Form(f): Form<super::secrets::SecretForm>,
714
) -> Result<Page<RepoSettingsPage>, Error> {
715
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
716
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
717
    csrf(&viewer, &f.csrf_token, &shell)?;
718
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
719
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
720
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
721
    }
722
}
723
724
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
725
pub async fn assistant_models(
726
    State(app): State<Shared>,
727
    viewer: Viewer,
728
    Path((owner, name)): Path<(String, String)>,
729
    Form(f): Form<super::assistants::ModelsForm>,
730
) -> Result<axum::Json<serde_json::Value>, Error> {
731
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
732
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
733
    csrf(&viewer, &f.csrf_token, &shell)?;
734
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
735
}