OXYGENHUB
# e71846aa07b9 oxygenhub / crates / hub / src / web / repo_settings.rs
23.4 KiB · Rust · last changed AI assistants: settings per site, organisation and repository 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 !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
64
        return Ok(fail("You cannot create repositories under that owner."));
65
    }
66
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
67
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
68
        || app.repos.exists(&name)
69
    {
70
        return Ok(fail("That repository exists."));
71
    }
72
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
73
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
74
        .await
75
        .map_err(|e| Error::from_db(shell.clone(), e))?;
76
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
77
    Ok(Redirect::to(&name.url()).into_response())
78
}
79
80
#[derive(Template)]
81
#[template(path = "repo_settings.html")]
82
pub struct RepoSettingsPage {
83
    shell: Shell,
84
    repo: RepoShell,
85
    row: db::repos::RepoRow,
86
    collaborators: Vec<accounts::Collaborator>,
87
    teams: Vec<accounts::Team>,
88
    role: Role,
89
    requirements: db::proposals::Requirements,
90
    hooks: Vec<super::webhooks::HookRow>,
91
    required_checks: String,
92
    /// Whether hub.toml alone would run Flow here, and whether it runs.
93
    flow_default: bool,
94
    flow_enabled: bool,
95
    site_admin: bool,
96
    notice: Option<String>,
97
    error: Option<String>,
98
    /// Runners that may take this repository's jobs: (runner, what it
99
    /// serves, whether it is this repository's own).
100
    runners: Vec<(db::runners::Runner, String, bool)>,
101
    /// A registration token just made, shown once.
102
    runner_token: Option<String>,
103
    /// The repository's AI assistants and prompt (#97).
104
    assistants: super::assistants::Panel,
105
}
106
107
async fn settings_page(
108
    app: &Shared,
109
    shell: Shell,
110
    row: db::repos::RepoRow,
111
    role: Role,
112
    notice: Option<String>,
113
    error: Option<String>,
114
) -> Result<Page<RepoSettingsPage>, Error> {
115
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
116
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
117
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
118
        None => vec![],
119
    };
120
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
121
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
122
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
123
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
124
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
125
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
126
    let flow_enabled = crate::flow::enabled(app, &row);
127
    let site_admin = shell.is_admin;
128
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
129
        .await
130
        .map_err(|e| Error::from_db(shell.clone(), e))?
131
        .into_iter()
132
        .map(|r| {
133
            let own = r.scope_repo_id == Some(row.id);
134
            let serves = if own {
135
                "this repository".to_string()
136
            } else if r.scope_owner.is_empty() {
137
                "the whole site".to_string()
138
            } else {
139
                format!("{}'s repositories", r.scope_owner)
140
            };
141
            (r, serves, own)
142
        })
143
        .collect();
144
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
145
    Ok(Page(RepoSettingsPage {
146
        assistants,
147
        runners,
148
        runner_token: None,
149
        shell,
150
        repo,
151
        row,
152
        collaborators,
153
        teams,
154
        role,
155
        requirements,
156
        hooks,
157
        required_checks,
158
        flow_default,
159
        flow_enabled,
160
        site_admin,
161
        notice,
162
        error,
163
    }))
164
}
165
166
pub async fn settings(
167
    State(app): State<Shared>,
168
    viewer: Viewer,
169
    Path((owner, name)): Path<(String, String)>,
170
) -> Result<Page<RepoSettingsPage>, Error> {
171
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
172
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
173
    settings_page(&app, shell, row, role, None, None).await
174
}
175
176
#[derive(Deserialize)]
177
pub struct GeneralForm {
178
    #[serde(rename = "_csrf")]
179
    csrf_token: String,
180
    description: String,
181
    default_bookmark: String,
182
    visibility: String,
183
    #[serde(default)]
184
    topics: String,
185
}
186
187
pub async fn update(
188
    State(app): State<Shared>,
189
    viewer: Viewer,
190
    Path((owner, name)): Path<(String, String)>,
191
    Form(f): Form<GeneralForm>,
192
) -> Result<Page<RepoSettingsPage>, Error> {
193
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
194
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
195
    csrf(&viewer, &f.csrf_token, &shell)?;
196
    let e = |e| Error::from_db(shell.clone(), e);
197
    let topics = match db::repos::parse_topics(&f.topics) {
198
        Ok(t) => t,
199
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
200
    };
201
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
202
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
203
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
204
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
205
    }
206
    if role >= Role::Admin {
207
        if let Some(v) = Visibility::parse(&f.visibility) {
208
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
209
        }
210
    }
211
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
212
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
213
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
214
}
215
216
#[derive(Deserialize)]
217
pub struct CollaboratorForm {
218
    #[serde(rename = "_csrf")]
219
    csrf_token: String,
220
    #[serde(default)]
221
    username: String,
222
    #[serde(default)]
223
    team: String,
224
    #[serde(default)]
225
    role: String,
226
    #[serde(default)]
227
    remove: Option<String>,
228
}
229
230
pub async fn collaborator(
231
    State(app): State<Shared>,
232
    viewer: Viewer,
233
    Path((owner, name)): Path<(String, String)>,
234
    Form(f): Form<CollaboratorForm>,
235
) -> Result<Page<RepoSettingsPage>, Error> {
236
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
237
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
238
    csrf(&viewer, &f.csrf_token, &shell)?;
239
    let e = |e| Error::from_db(shell.clone(), e);
240
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
241
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
242
            Some(a) => (Some(a.id), None, a.username),
243
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
244
        }
245
    } else if !f.team.trim().is_empty() {
246
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
247
        let team = match org {
248
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
249
            None => None,
250
        };
251
        match team {
252
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
253
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
254
        }
255
    } else {
256
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
257
    };
258
    if f.remove.is_some() {
259
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
260
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
261
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
262
    }
263
    let Some(new_role) = Role::parse(&f.role) else {
264
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
265
    };
266
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
267
    accounts::audit(
268
        &app.db,
269
        viewer.account(),
270
        "repo.collaborator.set",
271
        &format!("{owner}/{name}"),
272
        &format!("{label} {}", new_role.as_str()),
273
    )
274
    .await
275
    .ok();
276
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
277
}
278
279
#[derive(Deserialize)]
280
pub struct DeleteForm {
281
    #[serde(rename = "_csrf")]
282
    csrf_token: String,
283
    confirm: String,
284
}
285
286
pub async fn delete(
287
    State(app): State<Shared>,
288
    viewer: Viewer,
289
    Path((owner, name)): Path<(String, String)>,
290
    Form(f): Form<DeleteForm>,
291
) -> Result<Response, Error> {
292
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
293
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
294
    csrf(&viewer, &f.csrf_token, &shell)?;
295
    if f.confirm.trim() != repo.to_string() {
296
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
297
            .await?
298
            .into_response());
299
    }
300
    let _guard = app.repos.write_guard(&repo).await;
301
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
302
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
303
    crate::assistants::delete_scope(&app.db, crate::assistants::Scope::Repo(row.id)).await.ok();
304
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
305
    Ok(Redirect::to(&format!("/{owner}")).into_response())
306
}
307
308
#[derive(Deserialize)]
309
pub struct RequirementsForm {
310
    #[serde(rename = "_csrf")]
311
    csrf_token: String,
312
    #[serde(default)]
313
    required_approvals: i64,
314
    #[serde(default)]
315
    require_owners: Option<String>,
316
    #[serde(default)]
317
    allow_conflicts: Option<String>,
318
}
319
320
pub async fn requirements(
321
    State(app): State<Shared>,
322
    viewer: Viewer,
323
    Path((owner, name)): Path<(String, String)>,
324
    Form(f): Form<RequirementsForm>,
325
) -> Result<Page<RepoSettingsPage>, Error> {
326
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
327
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
328
    csrf(&viewer, &f.csrf_token, &shell)?;
329
    let r = db::proposals::Requirements {
330
        required_approvals: f.required_approvals.max(0),
331
        require_owners: f.require_owners.is_some(),
332
        allow_conflicts: f.allow_conflicts.is_some(),
333
    };
334
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
335
    accounts::audit(
336
        &app.db,
337
        viewer.account(),
338
        "repo.requirements",
339
        &format!("{owner}/{name}"),
340
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
341
    )
342
    .await
343
    .ok();
344
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
345
}
346
347
pub async fn hooks(
348
    State(app): State<Shared>,
349
    viewer: Viewer,
350
    Path((owner, name)): Path<(String, String)>,
351
    Form(f): Form<super::webhooks::HookForm>,
352
) -> Result<Page<RepoSettingsPage>, Error> {
353
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
354
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
355
    csrf(&viewer, &f.csrf_token, &shell)?;
356
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
357
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
358
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
359
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
360
    }
361
}
362
363
pub async fn deliveries(
364
    State(app): State<Shared>,
365
    viewer: Viewer,
366
    Path((owner, name, id)): Path<(String, String, i64)>,
367
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
368
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
369
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
370
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
371
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
372
}
373
374
pub async fn redeliver(
375
    State(app): State<Shared>,
376
    viewer: Viewer,
377
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
378
    Form(f): Form<super::session::CsrfForm>,
379
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
380
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
381
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
382
    csrf(&viewer, &f.csrf, &shell)?;
383
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
384
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
385
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
386
}
387
388
#[derive(Deserialize)]
389
pub struct ChecksForm {
390
    #[serde(rename = "_csrf")]
391
    csrf_token: String,
392
    #[serde(default)]
393
    required_checks: String,
394
}
395
396
pub async fn required_checks(
397
    State(app): State<Shared>,
398
    viewer: Viewer,
399
    Path((owner, name)): Path<(String, String)>,
400
    Form(f): Form<ChecksForm>,
401
) -> Result<Page<RepoSettingsPage>, Error> {
402
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
403
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
404
    csrf(&viewer, &f.csrf_token, &shell)?;
405
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
406
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
407
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
408
}
409
410
#[derive(Deserialize)]
411
pub struct MirrorForm {
412
    #[serde(rename = "_csrf")]
413
    csrf_token: String,
414
    #[serde(default)]
415
    enabled: Option<String>,
416
}
417
418
#[derive(Deserialize)]
419
pub struct FlowForm {
420
    #[serde(rename = "_csrf")]
421
    csrf_token: String,
422
    /// `on`, `off`, or `default`.
423
    flow: String,
424
}
425
426
/// Turn OxygenFlow on or off for one repository. Runs use the site's
427
/// runners, so only site administrators decide.
428
pub async fn flow(
429
    State(app): State<Shared>,
430
    viewer: Viewer,
431
    Path((owner, name)): Path<(String, String)>,
432
    Form(f): Form<FlowForm>,
433
) -> Result<Page<RepoSettingsPage>, Error> {
434
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
435
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
436
    csrf(&viewer, &f.csrf_token, &shell)?;
437
    if !viewer.is_admin() {
438
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
439
    }
440
    let setting = match f.flow.as_str() {
441
        "on" => "on",
442
        "off" => "off",
443
        "default" => "",
444
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
445
    };
446
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
447
    let label = if setting.is_empty() { "default" } else { setting };
448
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
449
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
450
    let notice = if crate::flow::enabled(&app, &row) {
451
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
452
    } else {
453
        "OxygenFlow is off for this repository."
454
    };
455
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
456
}
457
458
pub async fn mirror(
459
    State(app): State<Shared>,
460
    viewer: Viewer,
461
    Path((owner, name)): Path<(String, String)>,
462
    Form(f): Form<MirrorForm>,
463
) -> Result<Page<RepoSettingsPage>, Error> {
464
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
465
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
466
    csrf(&viewer, &f.csrf_token, &shell)?;
467
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
468
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
469
    if row.git_mirror {
470
        crate::mirror::refresh(&app, &row).await;
471
    }
472
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
473
        .await
474
        .ok();
475
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
476
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
477
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
478
}
479
480
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
481
/// token for a runner that serves this repository only (oxygenhub#41).
482
pub async fn runner_token(
483
    State(app): State<Shared>,
484
    viewer: Viewer,
485
    Path((owner, name)): Path<(String, String)>,
486
    Form(f): Form<super::session::CsrfForm>,
487
) -> Result<Page<RepoSettingsPage>, Error> {
488
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
489
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
490
    csrf(&viewer, &f.csrf, &shell)?;
491
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
492
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
493
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
494
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
495
        .await
496
        .map_err(|e| Error::from_db(shell.clone(), e))?;
497
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
498
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
499
    page.0.runner_token = Some(token);
500
    Ok(page)
501
}
502
503
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
504
/// repository's own runners, when it is running nothing.
505
pub async fn runner_remove(
506
    State(app): State<Shared>,
507
    viewer: Viewer,
508
    Path((owner, name, id)): Path<(String, String, i64)>,
509
    Form(f): Form<super::session::CsrfForm>,
510
) -> Result<Page<RepoSettingsPage>, Error> {
511
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
512
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
513
    csrf(&viewer, &f.csrf, &shell)?;
514
    let e = |e| Error::from_db(shell.clone(), e);
515
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
516
        return Err(Error::not_found(shell));
517
    };
518
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
519
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
520
    }
521
    db::runners::remove(&app.db, id).await.map_err(e)?;
522
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
523
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
524
}
525
526
fn repo_assistants(row: &db::repos::RepoRow) -> super::assistants::Where {
527
    super::assistants::Where {
528
        scope: crate::assistants::Scope::Repo(row.id),
529
        owner: row.owner.clone(),
530
        label: format!("{}/{}", row.owner, row.name),
531
    }
532
}
533
534
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
535
/// and prompt (#97), for its admins.
536
pub async fn assistants(
537
    State(app): State<Shared>,
538
    viewer: Viewer,
539
    Path((owner, name)): Path<(String, String)>,
540
    Form(f): Form<super::assistants::AssistantForm>,
541
) -> Result<Page<RepoSettingsPage>, Error> {
542
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
543
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
544
    csrf(&viewer, &f.csrf_token, &shell)?;
545
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
546
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
547
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
548
    }
549
}