OXYGENHUB

Repository settings: one page per setting under a grouped sidebar (#278) c7067c7bda56

Settings was one long page of anchored sections. Each setting is now a
page at /{owner}/{name}/settings/{page} (general, collaborators, landing,
checks, push-protection, flow, runs, gates, runners, variables, secrets,
coverage, webhooks, assistants, git-mirror, diffusion, storage, backup,
advanced) beside a sidebar grouped General, Code, Flow, Integrations,
Data and Danger zone, the current page marked with aria-current;
/settings shows General. Pages a role cannot use are left out of the
sidebar and not found; the admins' pages stay theirs. An archived
repository's settings open for its admins, who bring it back there.

Every form keeps its POST route and now redirects to the page holding
it with its notice or error (a runner token is still shown once in
place). Links elsewhere point at the pages, the push protection refusal
names /settings/push-protection, and a kept /settings#section link is
sent on by hub.js from the General page. On narrow screens the sidebar
is a compact list above the page. Tests cover every page by role, the
redirects and the marked page; the spec's pages table describes it.

Closes #278
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c505b80c7aa7
When
· 2026-10-20 15:11 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c7067c7bda562389bdf8e8e005ecce14a40ce4667862d998041d41551edec507
Depends on
0b20f1ec4007 0ee5c3fb32eb 107eaba52c9d 119fd17d213f 145306044896 1553bfc9ae53 155cab142b63 1870e4fa9808 18e381ea88c2 19bb31ff1f5c 1f78655a0466 206c1fc1b06a 2a83f5303458 3f4b0ea6eabf 42fa95540468 4a68f1064b08 4ac01a732552 4f6925337848 5e6d6484c28e 64caed18236a 66fd9d6b1452 7974adb0be66 7b26e6118bc0 7edaff01d506 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 961c10bee174 9756c67d3aec a160b98fb922 a7aa76ac52d7 a7e6e8e6094c a9f1c0bf403a b5668108487f b64e2537d702 cb7b3cd45dc0 d0fbea2eb46e d23464c0ac60 d3b048b27277 de505df5cf8d e71583501b1d e71846aa07b9 ea55fb62f919 f4d6ef418a96
Depended on by
5588bb3631d2 737de0fac722 367f34b8e590 d366306dae7d a0a1601eb5a5
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
273 atoms, 0 tree operations, 24 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Repository settings are one page per setting (#278):** a repository's Settings was one long page; it is now a sidebar of grouped pages (General, Code, Flow, Integrations, Data, Danger zone) with the page shown beside it, the current one marked, each at its own address (`/{owner}/{name}/settings/push-protection`, `/settings/runs`, …; `/settings` is General). Pages a role cannot use are not listed and are not found. Every form keeps its address and now returns to its own page with what happened, so reloading does not send it again. Links elsewhere point at the new pages (the push protection refusal names `/settings/push-protection`), and a kept `/settings#section` link opens the page that has it. On a narrow screen the sidebar becomes a compact list above the page. An archived repository's settings open for its admins again, so they can bring it back.
7 8
- **Proposal lists and the proposals API are much faster on large files:** listing a repository's proposals rendered every open proposal's full diff and looked up each new line's history separately, recomputing a whole file's lines for every line; lists, the API, auto-land and the checks after a push now skip the rendering, and the proposal page computes each file's lines once.
8 9
- **Keyless agents can push release tags (andymccall/oxygen#129):** A tag signed by a key the hub certified (an agent's or a Flow job's ten-minute key) was refused "unknown key", and then refused by the policy. With oxy's fix the hub accepts it under the policy's subject grant and countersigns when it accepted it, as it does a bookmark move. It logs the tag's acceptance in the transparency log beside the tag, and serves both with the tag in every refs answer, so a client fetching the tag months later checks the certificate at the hub's time. Runners read repositories with their own oxy, so they need rebuilding to check such tags; older ones ignore the new fields.
9 10
- **Secret scanning finds what is already in history (#277):** push protection's rules now also run over each repository's existing history in the background (every change reachable from its bookmarks: the lines it inserts and the text files it replaces), so a credential committed before push protection existed, or while it was off, is found too. Every repository's whole history is scanned once after this deploy, then only what each push adds, a budget at a time (`[limits] history_scan_limit`, 64 MiB, and `history_scan_time_ms`, 5 s, per repository per pass; `0` turns it off), at low priority, resuming where it stopped. Maintainers see the findings, with the change, bookmark, file and line, under Settings › Push protection › Found in history, and either allow one (not a secret) or mark it resolved with how it was revoked (audited as `secret.resolved`); "Scan history now" scans the whole history again. An allowed fingerprint can now be removed (audited as `secret.bypass_removed`), so pushes adding that value are refused again. Repositories with push protection off are not scanned.
…
256 unchanged lines
modified assets/hub.js +13 −0
…
915 unchanged lines
916 916
    sel.addEventListener("change", show);
917 917
    show();
918 918
  })();
919 +
920 +
  /* A repository's settings were one page with a section per #anchor;
921 +
     each is a page of its own now (oxygenhub#278). A kept link such as
922 +
     /settings#flow-priority opens the page that holds that section, when
923 +
     the viewer has it (its link is in the sidebar). */
924 +
  (function () {
925 +
    var nav = document.querySelector("[data-settings-anchors]");
926 +
    var hash = location.hash.slice(1);
927 +
    if (!nav || !/^[a-z-]+$/.test(hash)) return;
928 +
    var moved = { "history-scan": "push-protection", "flow-priority": "runs", "flow-supersede": "runs", "agent-gates": "gates" };
929 +
    var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]');
930 +
    if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash);
931 +
  })();
919 932
})();
modified crates/hub/src/push_protection.rs +2 −2
…
496 unchanged lines
497 497
    let _ = writeln!(out, "  - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere.");
498 498
    let _ = writeln!(
499 499
        out,
500 -
        "  - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}#push-protection; then push again."
500 +
        "  - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}/push-protection; then push again."
501 501
    );
502 502
    if !via.is_empty() {
503 503
        let _ = write!(
504 504
            out,
505 -
            "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}#push-protection."
505 +
            "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}/push-protection."
506 506
        );
507 507
    }
508 508
    out.trim_end().to_string()
…
72 unchanged lines
modified crates/hub/src/web/flow.rs +1 −1
…
45 unchanged lines
46 46
/// Why `viewer` may not start runs here, or `None` when they may.
47 47
pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> {
48 48
    if !enabled {
49 -
        Some("Flow is off for this repository; a site administrator can turn it on in Settings → Flow.".into())
49 +
        Some("Flow is off for this repository; a site administrator can turn it on in Settings › Pipelines.".into())
50 50
    } else if !signed_in {
51 51
        Some("Sign in with write access to run the pipeline.".into())
52 52
    } else if role < Role::Write {
…
949 unchanged lines
modified crates/hub/src/web/mod.rs +13 −11
…
224 unchanged lines
225 225
        .route("/{owner}/{name}/fork", get(forks::form).post(forks::create))
226 226
        .route("/{owner}/{name}/forks", get(forks::list))
227 227
        .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update))
228 -
        .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator))
228 +
        // One page per setting (#278); some share their form's path.
229 +
        .route("/{owner}/{name}/settings/{page}", get(repo_settings::page))
230 +
        .route("/{owner}/{name}/settings/collaborators", get(repo_settings::page).post(repo_settings::collaborator))
229 231
        .route("/{owner}/{name}/settings/delete", post(repo_settings::delete))
230 232
        .route("/{owner}/{name}/settings/transfer", post(repo_settings::transfer))
231 233
        .route("/{owner}/{name}/settings/archive", post(repo_settings::archive))
…
28 unchanged lines
260 262
        .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue))
261 263
        .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events))
262 264
        .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks))
263 -
        .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants))
265 +
        .route("/{owner}/{name}/settings/assistants", get(repo_settings::page).post(repo_settings::assistants))
264 266
        .route("/{owner}/{name}/settings/assistants/models", post(repo_settings::assistant_models))
265 -
        .route("/{owner}/{name}/settings/variables", post(repo_settings::variables))
266 -
        .route("/{owner}/{name}/settings/secrets", post(repo_settings::secrets))
267 +
        .route("/{owner}/{name}/settings/variables", get(repo_settings::page).post(repo_settings::variables))
268 +
        .route("/{owner}/{name}/settings/secrets", get(repo_settings::page).post(repo_settings::secrets))
267 269
        .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries))
268 270
        .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver))
269 -
        .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks))
271 +
        .route("/{owner}/{name}/settings/checks", get(repo_settings::page).post(repo_settings::required_checks))
270 272
        .route("/{owner}/{name}/watch", post(issues::watch))
271 273
        .route("/{owner}/{name}/star", post(issues::star))
272 274
        .route("/{owner}/{name}/edit/{r}", post(edit::save))
…
44 unchanged lines
317 319
        .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack))
318 320
        .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack))
319 321
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
320 -
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
322 +
        .route("/{owner}/{name}/settings/flow", get(repo_settings::page).post(repo_settings::flow))
321 323
        .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority))
322 324
        .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates))
323 -
        .route("/{owner}/{name}/settings/push-protection", post(repo_settings::push_protection))
325 +
        .route("/{owner}/{name}/settings/push-protection", get(repo_settings::page).post(repo_settings::push_protection))
324 326
        .route("/{owner}/{name}/settings/secret-bypass", post(repo_settings::secret_bypass))
325 327
        .route("/{owner}/{name}/settings/secret-bypass/remove", post(repo_settings::secret_bypass_remove))
326 328
        .route("/{owner}/{name}/settings/secret-resolve", post(repo_settings::secret_resolve))
327 329
        .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan))
328 330
        .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede))
329 -
        .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion))
330 -
        .route("/{owner}/{name}/settings/backup", post(repo_settings::backup))
331 -
        .route("/{owner}/{name}/settings/storage", post(repo_settings::storage))
332 -
        .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage))
331 +
        .route("/{owner}/{name}/settings/diffusion", get(repo_settings::page).post(repo_settings::diffusion))
332 +
        .route("/{owner}/{name}/settings/backup", get(repo_settings::page).post(repo_settings::backup))
333 +
        .route("/{owner}/{name}/settings/storage", get(repo_settings::page).post(repo_settings::storage))
334 +
        .route("/{owner}/{name}/settings/coverage", get(repo_settings::page).post(repo_settings::coverage))
333 335
        .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token))
334 336
        .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove))
335 337
        .route("/{owner}/{name}/issues", get(issues::list))
…
535 unchanged lines
modified crates/hub/src/web/repo_settings.rs +329 −142
…
6 unchanged lines
7 7
use crate::repos::RepoName;
8 8
use crate::Shared;
9 9
use askama::Template;
10 -
use axum::extract::{Form, Path, State};
10 +
use axum::extract::{Form, Path, Query, State};
11 +
use axum::http::Uri;
11 12
use axum::response::{IntoResponse, Redirect, Response};
12 13
use hub_api::Visibility;
13 14
use serde::Deserialize;
…
143 unchanged lines
157 158
    Ok(Redirect::to(&name.url()).into_response())
158 159
}
159 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 {
200 +
    pub fn slug(self) -> &'static str {
201 +
        match self {
202 +
            Section::General => "general",
203 +
            Section::Collaborators => "collaborators",
204 +
            Section::Landing => "landing",
205 +
            Section::Checks => "checks",
206 +
            Section::PushProtection => "push-protection",
207 +
            Section::Pipelines => "flow",
208 +
            Section::Runs => "runs",
209 +
            Section::Gates => "gates",
210 +
            Section::Runners => "runners",
211 +
            Section::Variables => "variables",
212 +
            Section::Secrets => "secrets",
213 +
            Section::Coverage => "coverage",
214 +
            Section::Webhooks => "webhooks",
215 +
            Section::Assistants => "assistants",
216 +
            Section::GitMirror => "git-mirror",
217 +
            Section::Diffusion => "diffusion",
218 +
            Section::Storage => "storage",
219 +
            Section::Backup => "backup",
220 +
            Section::Advanced => "advanced",
221 +
        }
222 +
    }
223 +
224 +
    pub fn title(self) -> &'static str {
225 +
        match self {
226 +
            Section::General => "General",
227 +
            Section::Collaborators => "Collaborators",
228 +
            Section::Landing => "Landing rules",
229 +
            Section::Checks => "Required checks",
230 +
            Section::PushProtection => "Push protection",
231 +
            Section::Pipelines => "Pipelines",
232 +
            Section::Runs => "Runs & priority",
233 +
            Section::Gates => "Gates & agents",
234 +
            Section::Runners => "Runners",
235 +
            Section::Variables => "Variables",
236 +
            Section::Secrets => "Secrets",
237 +
            Section::Coverage => "Coverage",
238 +
            Section::Webhooks => "Webhooks",
239 +
            Section::Assistants => "AI assistants",
240 +
            Section::GitMirror => "Git mirror",
241 +
            Section::Diffusion => "Diffusion",
242 +
            Section::Storage => "Storage",
243 +
            Section::Backup => "Backup",
244 +
            Section::Advanced => "Advanced",
245 +
        }
246 +
    }
247 +
248 +
    pub fn parse(slug: &str) -> Option<Section> {
249 +
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
250 +
    }
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.
254 +
    fn admin_only(self) -> bool {
255 +
        matches!(
256 +
            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 +
        )
266 +
    }
267 +
268 +
    /// Whether this viewer has the page: Storage only for a repository kept
269 +
    /// in a bucket.
270 +
    fn shown(self, role: Role, in_bucket: bool) -> bool {
271 +
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
272 +
    }
273 +
}
274 +
275 +
/// The sidebar a viewer sees: the groups with at least one page for them.
276 +
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
277 +
    GROUPS
278 +
        .iter()
279 +
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
280 +
        .filter(|(_, pages)| !pages.is_empty())
281 +
        .collect()
282 +
}
283 +
284 +
/// Back to the page that holds a settings form, with what happened (#278).
285 +
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
286 +
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
287 +
    if let Some(n) = notice {
288 +
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
289 +
    } else if let Some(e) = error {
290 +
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
291 +
    }
292 +
    Ok(Redirect::to(&to).into_response())
293 +
}
294 +
160 295
#[derive(Template)]
161 296
#[template(path = "repo_settings.html")]
162 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>)>,
163 301
    shell: Shell,
164 302
    repo: RepoShell,
165 303
    row: db::repos::RepoRow,
…
48 unchanged lines
214 352
    }
215 353
}
216 354
355 +
/// One settings page, or not found when the viewer's role does not have it.
217 356
async fn settings_page(
218 357
    app: &Shared,
219 358
    shell: Shell,
220 359
    row: db::repos::RepoRow,
221 360
    role: Role,
361 +
    section: Section,
222 362
    notice: Option<String>,
223 363
    error: Option<String>,
224 364
) -> Result<Page<RepoSettingsPage>, Error> {
365 +
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
366 +
    if !section.shown(role, storage.is_some()) {
367 +
        return Err(Error::not_found(shell));
368 +
    }
369 +
    let nav = sidebar(role, storage.is_some());
225 370
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
226 371
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
227 372
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
…
28 unchanged lines
256 401
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
257 402
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
258 403
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
259 -
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
260 404
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
261 405
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
262 406
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
3 unchanged lines
266 410
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
267 411
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
268 412
    Ok(Page(RepoSettingsPage {
413 +
        section,
414 +
        nav,
269 415
        push_findings,
270 416
        secret_bypasses,
271 417
        history_scan,
…
25 unchanged lines
297 443
        notice,
298 444
        error,
299 445
    }))
446 +
}
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>,
300 455
}
301 456
457 +
/// `GET /{owner}/{name}/settings`: the General page.
302 458
pub async fn settings(
303 459
    State(app): State<Shared>,
304 460
    viewer: Viewer,
305 461
    Path((owner, name)): Path<(String, String)>,
462 +
    Query(q): Query<Outcome>,
463 +
) -> Result<Page<RepoSettingsPage>, Error> {
464 +
    let path = format!("/{owner}/{name}/settings");
465 +
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
466 +
}
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.
471 +
pub async fn page(
472 +
    State(app): State<Shared>,
473 +
    viewer: Viewer,
474 +
    Path(params): Path<std::collections::HashMap<String, String>>,
475 +
    uri: Uri,
476 +
    Query(q): Query<Outcome>,
477 +
) -> Result<Page<RepoSettingsPage>, Error> {
478 +
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
479 +
    let path = format!("/{owner}/{name}/settings");
480 +
    let slug = uri.path().rsplit('/').next().unwrap_or("");
481 +
    let Some(section) = Section::parse(slug) else {
482 +
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
483 +
    };
484 +
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
485 +
}
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.
489 +
async fn show(
490 +
    app: &Shared,
491 +
    viewer: &Viewer,
492 +
    path: &str,
493 +
    owner: &str,
494 +
    name: &str,
495 +
    section: Section,
496 +
    q: Outcome,
306 497
) -> Result<Page<RepoSettingsPage>, Error> {
307 -
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
308 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
309 -
    settings_page(&app, shell, row, role, None, None).await
498 +
    let shell = Shell::new(app, path, viewer);
499 +
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
500 +
    if role < Role::Maintain {
501 +
        return Err(Error::not_found(shell));
502 +
    }
503 +
    if row.archived && role < Role::Admin {
504 +
        return Err(super::archived(&shell));
505 +
    }
506 +
    settings_page(app, shell, row, role, section, q.notice, q.error).await
310 507
}
311 508
312 509
#[derive(Deserialize)]
…
12 unchanged lines
325 522
    viewer: Viewer,
326 523
    Path((owner, name)): Path<(String, String)>,
327 524
    Form(f): Form<GeneralForm>,
328 -
) -> Result<Page<RepoSettingsPage>, Error> {
525 +
) -> Result<Response, Error> {
329 526
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
330 527
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
331 528
    csrf(&viewer, &f.csrf_token, &shell)?;
332 529
    let e = |e| Error::from_db(shell.clone(), e);
333 530
    let topics = match db::repos::parse_topics(&f.topics) {
334 531
        Ok(t) => t,
335 -
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
532 +
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
336 533
    };
337 534
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
338 535
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
…
7 unchanged lines
346 543
    }
347 544
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
348 545
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
349 -
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
546 +
    back(&row, Section::General, Some("Saved.".into()), None)
350 547
}
351 548
352 549
#[derive(Deserialize)]
…
15 unchanged lines
368 565
    viewer: Viewer,
369 566
    Path((owner, name)): Path<(String, String)>,
370 567
    Form(f): Form<CollaboratorForm>,
371 -
) -> Result<Page<RepoSettingsPage>, Error> {
568 +
) -> Result<Response, Error> {
372 569
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
373 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
570 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
374 571
    csrf(&viewer, &f.csrf_token, &shell)?;
375 572
    let e = |e| Error::from_db(shell.clone(), e);
376 573
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
377 574
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
378 575
            Some(a) => (Some(a.id), None, a.username),
379 -
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
576 +
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
380 577
        }
381 578
    } else if !f.team.trim().is_empty() {
382 579
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
…
3 unchanged lines
386 583
        };
387 584
        match team {
388 585
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
389 -
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
586 +
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
390 587
        }
391 588
    } else {
392 -
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
589 +
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
393 590
    };
394 591
    if f.remove.is_some() {
395 592
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
396 593
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
397 -
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
594 +
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
398 595
    }
399 596
    let Some(new_role) = Role::parse(&f.role) else {
400 -
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
597 +
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
401 598
    };
402 599
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
403 600
    accounts::audit(
…
5 unchanged lines
409 606
    )
410 607
    .await
411 608
    .ok();
412 -
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
609 +
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
413 610
}
414 611
415 612
#[derive(Deserialize)]
…
14 unchanged lines
430 627
    Form(f): Form<TransferForm>,
431 628
) -> Result<Response, Error> {
432 629
    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?;
630 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
434 631
    csrf(&viewer, &f.csrf_token, &shell)?;
435 632
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
436 -
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
633 +
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
437 634
    if f.confirm.trim() != repo.to_string() {
438 -
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
635 +
        return fail("Type the repository's full name to confirm.");
439 636
    }
440 637
    let to = f.owner.trim().trim_start_matches('@').to_string();
441 638
    let Some(dest) = RepoName::new(&to, &row.name) else {
442 -
        return Ok(fail("That is not an owner name.").await?.into_response());
639 +
        return fail("That is not an owner name.");
443 640
    };
444 641
    if dest.owner == row.owner {
445 -
        return Ok(fail("The repository is already there.").await?.into_response());
642 +
        return fail("The repository is already there.");
446 643
    }
447 644
    let e = |e| Error::from_db(shell.clone(), e);
448 645
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
449 646
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
450 647
    if !known {
451 -
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
648 +
        return fail("There is no user or organisation of that name.");
452 649
    }
453 650
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
454 -
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
651 +
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
455 652
    }
456 653
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
457 -
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
654 +
        return fail("That owner already has a repository of this name.");
458 655
    }
459 656
    {
460 657
        let _guard = app.repos.exclusive_guard(&repo).await;
…
14 unchanged lines
475 672
    }
476 673
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
477 674
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
478 -
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
675 +
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
479 676
}
480 677
481 678
#[derive(Deserialize)]
…
13 unchanged lines
495 692
    viewer: Viewer,
496 693
    Path((owner, name)): Path<(String, String)>,
497 694
    Form(f): Form<ArchiveForm>,
498 -
) -> Result<Page<RepoSettingsPage>, Error> {
695 +
) -> Result<Response, Error> {
499 696
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
500 -
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
697 +
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
501 698
    csrf(&viewer, &f.csrf_token, &shell)?;
502 699
    let archive = match f.action.as_str() {
503 700
        "archive" => true,
504 701
        "unarchive" => false,
505 -
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
702 +
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
506 703
    };
507 704
    let reason: String = f.reason.trim().chars().take(500).collect();
508 705
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
7 unchanged lines
516 713
    } else {
517 714
        "The repository is back: writes are allowed again."
518 715
    };
519 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
716 +
    back(&row, Section::Advanced, Some(notice.into()), None)
520 717
}
521 718
522 719
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
…
2 unchanged lines
525 722
    viewer: Viewer,
526 723
    Path((owner, name)): Path<(String, String)>,
527 724
    Form(f): Form<DeleteForm>,
528 -
) -> Result<Page<RepoSettingsPage>, Error> {
725 +
) -> Result<Response, Error> {
529 726
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
530 -
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
727 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
531 728
    csrf(&viewer, &f.csrf_token, &shell)?;
532 729
    if f.confirm.trim() != repo.to_string() {
533 -
        return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await;
730 +
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
534 731
    }
535 732
    if !crate::wiki::exists(&app, &repo) {
536 -
        return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await;
733 +
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
537 734
    }
538 735
    let _guard = app.wikis.exclusive_guard(&repo).await;
539 736
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
540 737
    drop(_guard);
541 738
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
542 -
    settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await
739 +
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
543 740
}
544 741
545 742
#[derive(Deserialize)]
…
10 unchanged lines
556 753
    Form(f): Form<DeleteForm>,
557 754
) -> Result<Response, Error> {
558 755
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
559 -
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
756 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
560 757
    csrf(&viewer, &f.csrf_token, &shell)?;
561 758
    if f.confirm.trim() != repo.to_string() {
562 -
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
563 -
            .await?
564 -
            .into_response());
759 +
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
565 760
    }
566 761
    let _guard = app.repos.exclusive_guard(&repo).await;
567 762
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
…
25 unchanged lines
593 788
    viewer: Viewer,
594 789
    Path((owner, name)): Path<(String, String)>,
595 790
    Form(f): Form<RequirementsForm>,
596 -
) -> Result<Page<RepoSettingsPage>, Error> {
791 +
) -> Result<Response, Error> {
597 792
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
598 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
793 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
599 794
    csrf(&viewer, &f.csrf_token, &shell)?;
600 795
    let r = db::proposals::Requirements {
601 796
        required_approvals: f.required_approvals.max(0),
…
10 unchanged lines
612 807
    )
613 808
    .await
614 809
    .ok();
615 -
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
810 +
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
616 811
}
617 812
618 813
pub async fn hooks(
…
1 unchanged lines
620 815
    viewer: Viewer,
621 816
    Path((owner, name)): Path<(String, String)>,
622 817
    Form(f): Form<super::webhooks::HookForm>,
623 -
) -> Result<Page<RepoSettingsPage>, Error> {
818 +
) -> Result<Response, Error> {
624 819
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
625 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
820 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
626 821
    csrf(&viewer, &f.csrf_token, &shell)?;
627 822
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
628 823
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
629 -
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
630 -
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
824 +
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
825 +
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
631 826
    }
632 827
}
633 828
…
35 unchanged lines
669 864
    viewer: Viewer,
670 865
    Path((owner, name)): Path<(String, String)>,
671 866
    Form(f): Form<ChecksForm>,
672 -
) -> Result<Page<RepoSettingsPage>, Error> {
867 +
) -> Result<Response, Error> {
673 868
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
674 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
869 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
675 870
    csrf(&viewer, &f.csrf_token, &shell)?;
676 871
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
677 872
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
678 -
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
873 +
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
679 874
}
680 875
681 876
#[derive(Deserialize)]
…
12 unchanged lines
694 889
    viewer: Viewer,
695 890
    Path((owner, name)): Path<(String, String)>,
696 891
    Form(f): Form<CoverageForm>,
697 -
) -> Result<Page<RepoSettingsPage>, Error> {
892 +
) -> Result<Response, Error> {
698 893
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
699 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
894 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
700 895
    csrf(&viewer, &f.csrf_token, &shell)?;
701 896
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
702 897
        let v = v.trim().trim_end_matches('%').trim();
…
7 unchanged lines
710 905
    };
711 906
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
712 907
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
713 -
        (Err(e), _) | (_, Err(e)) => return settings_page(&app, shell, row, role, None, Some(e)).await,
908 +
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
714 909
    };
715 910
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
716 911
    accounts::audit(
…
5 unchanged lines
722 917
    )
723 918
    .await
724 919
    .ok();
725 -
    settings_page(&app, shell, row, role, Some("Coverage rules saved; they apply from the next report.".into()), None).await
920 +
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
726 921
}
727 922
728 923
#[derive(Deserialize)]
…
13 unchanged lines
742 937
    viewer: Viewer,
743 938
    Path((owner, name)): Path<(String, String)>,
744 939
    Form(f): Form<StorageForm>,
745 -
) -> Result<Page<RepoSettingsPage>, Error> {
940 +
) -> Result<Response, Error> {
746 941
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
747 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
942 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
748 943
    csrf(&viewer, &f.csrf_token, &shell)?;
749 944
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
750 -
        return settings_page(&app, shell, row, role, None, Some("This repository is kept on this server's disk.".into())).await;
945 +
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
751 946
    };
752 947
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
753 948
    let outcome = match f.action.as_str() {
…
6 unchanged lines
760 955
    match outcome {
761 956
        Ok(notice) => {
762 957
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
763 -
            settings_page(&app, shell, row, role, Some(notice), None).await
958 +
            back(&row, Section::Storage, Some(notice), None)
764 959
        }
765 -
        Err(e) => settings_page(&app, shell, row, role, None, Some(format!("Storage: {e}"))).await,
960 +
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
766 961
    }
767 962
}
768 963
…
21 unchanged lines
790 985
    viewer: Viewer,
791 986
    Path((owner, name)): Path<(String, String)>,
792 987
    Form(f): Form<BackupForm>,
793 -
) -> Result<Page<RepoSettingsPage>, Error> {
988 +
) -> Result<Response, Error> {
794 989
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
795 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
990 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
796 991
    csrf(&viewer, &f.csrf_token, &shell)?;
797 992
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
798 -
        return settings_page(&app, shell, row, role, None, Some("Pick how this repository is backed up.".into())).await;
993 +
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
799 994
    }
800 995
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
801 996
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
…
6 unchanged lines
808 1003
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
809 1004
        }
810 1005
    };
811 -
    settings_page(&app, shell, row, role, Some(notice), None).await
1006 +
    back(&row, Section::Backup, Some(notice), None)
812 1007
}
813 1008
814 1009
#[derive(Deserialize)]
…
14 unchanged lines
829 1024
    viewer: Viewer,
830 1025
    Path((owner, name)): Path<(String, String)>,
831 1026
    Form(f): Form<DiffusionForm>,
832 -
) -> Result<Page<RepoSettingsPage>, Error> {
1027 +
) -> Result<Response, Error> {
833 1028
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
834 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1029 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
835 1030
    csrf(&viewer, &f.csrf_token, &shell)?;
836 1031
    let setting = match f.diffusion.as_str() {
837 1032
        "off" | "green" | "any" => f.diffusion.as_str(),
838 1033
        "default" => "",
839 -
        _ => return settings_page(&app, shell, row, role, None, Some("Pick off, green, any or the site default.".into())).await,
1034 +
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
840 1035
    };
841 1036
    let land = match f.land.as_str() {
842 1037
        "auto" | "propose" => Some(f.land.as_str()),
843 1038
        "" => None,
844 -
        _ => return settings_page(&app, shell, row, role, None, Some("Pick propose or auto for landing.".into())).await,
1039 +
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
845 1040
    };
846 1041
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
847 1042
    if let Some(l) = land {
…
19 unchanged lines
867 1062
        }
868 1063
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
869 1064
    };
870 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1065 +
    back(&row, Section::Diffusion, Some(notice.into()), None)
871 1066
}
872 1067
873 1068
#[derive(Deserialize)]
…
13 unchanged lines
887 1082
    viewer: Viewer,
888 1083
    Path((owner, name)): Path<(String, String)>,
889 1084
    Form(f): Form<AgentGatesForm>,
890 -
) -> Result<Page<RepoSettingsPage>, Error> {
1085 +
) -> Result<Response, Error> {
891 1086
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
892 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1087 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
893 1088
    csrf(&viewer, &f.csrf_token, &shell)?;
894 1089
    let on = f.agents == "on";
895 1090
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
6 unchanged lines
902 1097
    } else {
903 1098
        "Approval gates here are decided by people: agents' tokens are refused."
904 1099
    };
905 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1100 +
    back(&row, Section::Gates, Some(notice.into()), None)
906 1101
}
907 1102
908 1103
#[derive(Deserialize)]
…
13 unchanged lines
922 1117
    viewer: Viewer,
923 1118
    Path((owner, name)): Path<(String, String)>,
924 1119
    Form(f): Form<PushProtectionForm>,
925 -
) -> Result<Page<RepoSettingsPage>, Error> {
1120 +
) -> Result<Response, Error> {
926 1121
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
927 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1122 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
928 1123
    csrf(&viewer, &f.csrf_token, &shell)?;
929 1124
    let on = f.protect == "on";
930 1125
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
10 unchanged lines
941 1136
    } else {
942 1137
        "Push protection is off: pushes are no longer scanned for secrets here."
943 1138
    };
944 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1139 +
    back(&row, Section::PushProtection, Some(notice.into()), None)
945 1140
}
946 1141
947 1142
#[derive(Deserialize)]
…
13 unchanged lines
961 1156
    viewer: Viewer,
962 1157
    Path((owner, name)): Path<(String, String)>,
963 1158
    Form(f): Form<SecretBypassForm>,
964 -
) -> Result<Page<RepoSettingsPage>, Error> {
1159 +
) -> Result<Response, Error> {
965 1160
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
966 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1161 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
967 1162
    csrf(&viewer, &f.csrf_token, &shell)?;
968 1163
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
969 1164
    let reason = f.reason.trim();
…
7 unchanged lines
977 1172
        None
978 1173
    };
979 1174
    if let Some(e) = error {
980 -
        return settings_page(&app, shell, row, role, None, Some(e.into())).await;
1175 +
        return back(&row, Section::PushProtection, None, Some(e.into()));
981 1176
    }
982 1177
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
983 1178
        .await
…
4 unchanged lines
988 1183
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
989 1184
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
990 1185
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
991 -
    settings_page(&app, shell, row, role, Some(notice), None).await
1186 +
    back(&row, Section::PushProtection, Some(notice), None)
992 1187
}
993 1188
994 1189
#[derive(Deserialize)]
…
11 unchanged lines
1006 1201
    viewer: Viewer,
1007 1202
    Path((owner, name)): Path<(String, String)>,
1008 1203
    Form(f): Form<BypassRemoveForm>,
1009 -
) -> Result<Page<RepoSettingsPage>, Error> {
1204 +
) -> Result<Response, Error> {
1010 1205
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1011 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1206 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1012 1207
    csrf(&viewer, &f.csrf_token, &shell)?;
1013 1208
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1014 1209
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1015 1210
    let Some(b) = removed else {
1016 -
        return settings_page(&app, shell, row, role, None, Some(format!("Fingerprint {fingerprint} is not allowed here."))).await;
1211 +
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1017 1212
    };
1018 1213
    let detail = if b.rule.is_empty() {
1019 1214
        format!("{fingerprint} (was: {})", b.reason)
…
2 unchanged lines
1022 1217
    };
1023 1218
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
1024 1219
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
1025 -
    settings_page(&app, shell, row, role, Some(notice), None).await
1220 +
    back(&row, Section::PushProtection, Some(notice), None)
1026 1221
}
1027 1222
1028 1223
#[derive(Deserialize)]
…
13 unchanged lines
1042 1237
    viewer: Viewer,
1043 1238
    Path((owner, name)): Path<(String, String)>,
1044 1239
    Form(f): Form<SecretResolveForm>,
1045 -
) -> Result<Page<RepoSettingsPage>, Error> {
1240 +
) -> Result<Response, Error> {
1046 1241
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1047 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1242 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1048 1243
    csrf(&viewer, &f.csrf_token, &shell)?;
1049 1244
    let reason = f.reason.trim();
1050 1245
    let error = if reason.is_empty() {
…
4 unchanged lines
1055 1250
        None
1056 1251
    };
1057 1252
    if let Some(e) = error {
1058 -
        return settings_page(&app, shell, row, role, None, Some(e.into())).await;
1253 +
        return back(&row, Section::PushProtection, None, Some(e.into()));
1059 1254
    }
1060 1255
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
1061 1256
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1062 1257
    let Some(found) = done else {
1063 -
        return settings_page(&app, shell, row, role, None, Some("That finding is not open here.".into())).await;
1258 +
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1064 1259
    };
1065 1260
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
1066 1261
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
1067 1262
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
1068 -
    settings_page(&app, shell, row, role, Some(notice), None).await
1263 +
    back(&row, Section::PushProtection, Some(notice), None)
1069 1264
}
1070 1265
1071 1266
#[derive(Deserialize)]
…
10 unchanged lines
1082 1277
    viewer: Viewer,
1083 1278
    Path((owner, name)): Path<(String, String)>,
1084 1279
    Form(f): Form<SecretScanForm>,
1085 -
) -> Result<Page<RepoSettingsPage>, Error> {
1280 +
) -> Result<Response, Error> {
1086 1281
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1087 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1282 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1088 1283
    csrf(&viewer, &f.csrf_token, &shell)?;
1089 1284
    if !row.push_protection {
1090 -
        return settings_page(
1091 -
            &app,
1092 -
            shell,
1093 -
            row,
1094 -
            role,
1285 +
        return back(
1286 +
            &row,
1287 +
            Section::PushProtection,
1095 1288
            None,
1096 1289
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1097 -
        )
1098 -
        .await;
1290 +
        );
1099 1291
    }
1100 1292
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1101 1293
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
1102 1294
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
1103 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1295 +
    back(&row, Section::PushProtection, Some(notice.into()), None)
1104 1296
}
1105 1297
1106 1298
#[derive(Deserialize)]
…
14 unchanged lines
1121 1313
    viewer: Viewer,
1122 1314
    Path((owner, name)): Path<(String, String)>,
1123 1315
    Form(f): Form<SupersedeForm>,
1124 -
) -> Result<Page<RepoSettingsPage>, Error> {
1316 +
) -> Result<Response, Error> {
1125 1317
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1126 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1318 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1127 1319
    csrf(&viewer, &f.csrf_token, &shell)?;
1128 1320
    let keep = f.running != "on";
1129 1321
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
6 unchanged lines
1136 1328
    } else {
1137 1329
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1138 1330
    };
1139 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1331 +
    back(&row, Section::Runs, Some(notice.into()), None)
1140 1332
}
1141 1333
1142 1334
#[derive(Deserialize)]
…
12 unchanged lines
1155 1347
    viewer: Viewer,
1156 1348
    Path((owner, name)): Path<(String, String)>,
1157 1349
    Form(f): Form<PriorityForm>,
1158 -
) -> Result<Page<RepoSettingsPage>, Error> {
1350 +
) -> Result<Response, Error> {
1159 1351
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1160 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1352 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1161 1353
    csrf(&viewer, &f.csrf_token, &shell)?;
1162 1354
    let level = match f.priority.as_str() {
1163 1355
        "low" | "normal" | "high" => f.priority.as_str(),
1164 1356
        "default" => "",
1165 -
        _ => return settings_page(&app, shell, row, role, None, Some("Pick low, normal, high or the default.".into())).await,
1357 +
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1166 1358
    };
1167 1359
    if level == "high" && !viewer.is_admin() {
1168 -
        return settings_page(&app, shell, row, role, None, Some("Only site administrators raise a repository to high.".into())).await;
1360 +
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
1169 1361
    }
1170 1362
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1171 1363
    accounts::audit(
…
11 unchanged lines
1183 1375
    } else {
1184 1376
        format!("Its jobs are {level} priority.")
1185 1377
    };
1186 -
    settings_page(&app, shell, row, role, Some(notice), None).await
1378 +
    back(&row, Section::Runs, Some(notice), None)
1187 1379
}
1188 1380
1189 1381
#[derive(Deserialize)]
…
13 unchanged lines
1203 1395
    viewer: Viewer,
1204 1396
    Path((owner, name)): Path<(String, String)>,
1205 1397
    Form(f): Form<FlowForm>,
1206 -
) -> Result<Page<RepoSettingsPage>, Error> {
1398 +
) -> Result<Response, Error> {
1207 1399
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1208 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1400 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1209 1401
    csrf(&viewer, &f.csrf_token, &shell)?;
1210 1402
    let setting = match f.flow.as_str() {
1211 1403
        "on" => "on",
1212 1404
        "own" => "own",
1213 1405
        "off" => "off",
1214 1406
        "default" => "",
1215 -
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, own runners, off or the site default.".into())).await,
1407 +
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1216 1408
    };
1217 1409
    if !viewer.is_admin() {
1218 1410
        if matches!(setting, "on" | "") {
1219 -
            return settings_page(
1220 -
                &app,
1221 -
                shell,
1222 -
                row,
1223 -
                role,
1411 +
            return back(
1412 +
                &row,
1413 +
                Section::Pipelines,
1224 1414
                None,
1225 1415
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1226 -
            )
1227 -
            .await;
1416 +
            );
1228 1417
        }
1229 1418
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1230 -
            return settings_page(
1231 -
                &app,
1232 -
                shell,
1233 -
                row,
1234 -
                role,
1419 +
            return back(
1420 +
                &row,
1421 +
                Section::Pipelines,
1235 1422
                None,
1236 -
                Some("Register a runner for this repository (Runners, below) or its owner first.".into()),
1237 -
            )
1238 -
            .await;
1423 +
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1424 +
            );
1239 1425
        }
1240 1426
    }
1241 1427
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
7 unchanged lines
1249 1435
    } else {
1250 1436
        "OxygenFlow is off for this repository."
1251 1437
    };
1252 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1438 +
    back(&row, Section::Pipelines, Some(notice.into()), None)
1253 1439
}
1254 1440
1255 1441
pub async fn mirror(
…
1 unchanged lines
1257 1443
    viewer: Viewer,
1258 1444
    Path((owner, name)): Path<(String, String)>,
1259 1445
    Form(f): Form<MirrorForm>,
1260 -
) -> Result<Page<RepoSettingsPage>, Error> {
1446 +
) -> Result<Response, Error> {
1261 1447
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1262 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1448 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1263 1449
    csrf(&viewer, &f.csrf_token, &shell)?;
1264 1450
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1265 1451
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
…
5 unchanged lines
1271 1457
        .ok();
1272 1458
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1273 1459
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
1274 -
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1460 +
    back(&row, Section::GitMirror, Some(notice.into()), None)
1275 1461
}
1276 1462
1277 1463
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
…
14 unchanged lines
1292 1478
        .await
1293 1479
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1294 1480
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
1295 -
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
1481 +
    let mut page =
1482 +
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
1296 1483
    page.0.runner_token = Some(token);
1297 1484
    Ok(page)
1298 1485
}
…
5 unchanged lines
1304 1491
    viewer: Viewer,
1305 1492
    Path((owner, name, id)): Path<(String, String, i64)>,
1306 1493
    Form(f): Form<super::session::CsrfForm>,
1307 -
) -> Result<Page<RepoSettingsPage>, Error> {
1494 +
) -> Result<Response, Error> {
1308 1495
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1309 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1496 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1310 1497
    csrf(&viewer, &f.csrf, &shell)?;
1311 1498
    let e = |e| Error::from_db(shell.clone(), e);
1312 1499
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1313 1500
        return Err(Error::not_found(shell));
1314 1501
    };
1315 1502
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
1316 -
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
1503 +
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
1317 1504
    }
1318 1505
    db::runners::remove(&app.db, id).await.map_err(e)?;
1319 1506
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
1320 -
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
1507 +
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
1321 1508
}
1322 1509
1323 1510
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
…
7 unchanged lines
1331 1518
    viewer: Viewer,
1332 1519
    Path((owner, name)): Path<(String, String)>,
1333 1520
    Form(f): Form<super::assistants::AssistantForm>,
1334 -
) -> Result<Page<RepoSettingsPage>, Error> {
1521 +
) -> Result<Response, Error> {
1335 1522
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1336 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1523 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1337 1524
    csrf(&viewer, &f.csrf_token, &shell)?;
1338 1525
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1339 -
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1340 -
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1526 +
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1527 +
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1341 1528
    }
1342 1529
}
1343 1530
…
4 unchanged lines
1348 1535
    viewer: Viewer,
1349 1536
    Path((owner, name)): Path<(String, String)>,
1350 1537
    Form(f): Form<super::variables::VariableForm>,
1351 -
) -> Result<Page<RepoSettingsPage>, Error> {
1538 +
) -> Result<Response, Error> {
1352 1539
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1353 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1540 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1354 1541
    csrf(&viewer, &f.csrf_token, &shell)?;
1355 1542
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1356 -
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1357 -
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1543 +
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1544 +
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1358 1545
    }
1359 1546
}
1360 1547
…
4 unchanged lines
1365 1552
    viewer: Viewer,
1366 1553
    Path((owner, name)): Path<(String, String)>,
1367 1554
    Form(f): Form<super::secrets::SecretForm>,
1368 -
) -> Result<Page<RepoSettingsPage>, Error> {
1555 +
) -> Result<Response, Error> {
1369 1556
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1370 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1557 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1371 1558
    csrf(&viewer, &f.csrf_token, &shell)?;
1372 1559
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1373 -
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1374 -
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1560 +
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1561 +
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1375 1562
    }
1376 1563
}
1377 1564
…
18 unchanged lines
modified crates/hub/src/web/webhooks.rs +12 −1
…
29 unchanged lines
30 30
        }
31 31
    }
32 32
33 +
    /// Where its webhooks are listed: a repository's own settings page
34 +
    /// (#278), an organisation's section.
35 +
    pub fn webhooks_url(&self) -> String {
36 +
        match self {
37 +
            Scope::Repo { slug, .. } => format!("/{slug}/settings/webhooks"),
38 +
            Scope::Org { name, .. } => format!("/orgs/{name}/settings#webhooks"),
39 +
        }
40 +
    }
41 +
33 42
    fn label(&self) -> &str {
34 43
        match self {
35 44
            Scope::Repo { slug, .. } => slug,
…
152 unchanged lines
188 197
    hook: db::hooks::Hook,
189 198
    rows: Vec<db::hooks::Delivery>,
190 199
    settings_url: String,
200 +
    /// The page listing the webhooks, to go back to.
201 +
    back_url: String,
191 202
}
192 203
193 204
pub async fn deliveries_page(
…
9 unchanged lines
203 214
        .filter(|h| scope.owns(h))
204 215
        .ok_or_else(|| Error::not_found(shell.clone()))?;
205 216
    let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?;
206 -
    Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url() }))
217 +
    Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url(), back_url: scope.webhooks_url() }))
207 218
}
208 219
209 220
pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> {
…
7 unchanged lines
modified crates/hub/tests/accounts.rs +123 −4
…
97 unchanged lines
98 98
    send(app, "POST", path, cookie, Some(form)).await
99 99
}
100 100
101 +
/// A repository settings form's answer (#278): a redirect to the page that
102 +
/// holds the form, followed.
103 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
104 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
105 +
    let to = r.location.expect("a redirect");
106 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
107 +
    get(app, &to, cookie).await
108 +
}
109 +
101 110
/// Register (or sign in) and return the session cookie value.
102 111
async fn register(app: &Shared, user: &str, invite: &str) -> String {
103 112
    let r = post(
…
91 unchanged lines
195 204
    assert_eq!(settings.status, StatusCode::OK);
196 205
    let csrf = csrf_of(&settings.body);
197 206
    let r = post(app, "/andy/secret/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
198 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
207 +
    let r = settled(app, r, Some(&andy), "/andy/secret/settings/collaborators").await;
208 +
    assert!(r.body.contains("bob now has write access."), "{}", r.body);
199 209
    assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::OK);
200 210
    assert_eq!(get(app, "/andy/secret/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "write is not maintain");
201 211
    std::env::set_var("OXY_TOKEN", &bob_tok);
…
182 unchanged lines
384 394
    let mut out = Vec::new();
385 395
    img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
386 396
    out
397 +
}
398 +
399 +
/// Repository settings (oxygenhub#278): one page per setting beside a
400 +
/// grouped sidebar that marks the page shown; a role sees only its pages
401 +
/// and the others are not found; each form returns to the page holding it.
402 +
#[tokio::test(flavor = "multi_thread")]
403 +
async fn repository_settings_are_a_page_each_by_role() {
404 +
    let h = hub().await;
405 +
    let app = &h.app;
406 +
    let andy = register(app, "andy", "").await;
407 +
    let mut invited = vec![];
408 +
    for name in ["bob", "carol"] {
409 +
        let csrf = csrf_of(&get(app, "/admin", Some(&andy)).await.body);
410 +
        let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await;
411 +
        let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string();
412 +
        invited.push(register(app, name, &code).await);
413 +
    }
414 +
    let (bob, carol) = (invited[0].clone(), invited[1].clone());
415 +
    let csrf = csrf_of(&get(app, "/new", Some(&andy)).await.body);
416 +
    let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=demo&visibility=public")).await;
417 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
418 +
    let csrf = csrf_of(&get(app, "/andy/demo/settings", Some(&andy)).await.body);
419 +
    for (who, role) in [("bob", "maintain"), ("carol", "write")] {
420 +
        let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username={who}&role={role}")).await;
421 +
        let page = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await;
422 +
        assert!(page.body.contains(&format!("{who} now has {role} access.")), "{}", page.body);
423 +
    }
424 +
425 +
    let marked = |slug: &str| format!("href=\"/andy/demo/settings/{slug}\" data-slug=\"{slug}\" class=\"on\" aria-current=\"page\"");
426 +
    let maintainers =
427 +
        ["general", "landing", "checks", "push-protection", "flow", "runs", "gates", "coverage", "git-mirror", "diffusion", "backup"];
428 +
    let admins = ["collaborators", "runners", "variables", "secrets", "webhooks", "assistants", "advanced"];
429 +
    // /settings is General, marked in the sidebar, with the groups' headings.
430 +
    let general = get(app, "/andy/demo/settings", Some(&andy)).await;
431 +
    assert_eq!(general.status, StatusCode::OK);
432 +
    assert!(general.body.contains(&marked("general")) && general.body.contains("id=\"general\""), "{}", general.body);
433 +
    for group in ["General", "Code", "Flow", "Integrations", "Data", "Danger zone"] {
434 +
        assert!(general.body.contains(&format!("<h2>{group}</h2>")), "group {group}");
435 +
    }
436 +
    assert!(general.body.contains("data-settings-anchors"), "kept #anchor links are sent on from General");
437 +
    // Every page renders for an admin, marked; one page's sections only.
438 +
    for slug in maintainers.iter().chain(admins.iter()) {
439 +
        let page = get(app, &format!("/andy/demo/settings/{slug}"), Some(&andy)).await;
440 +
        assert_eq!(page.status, StatusCode::OK, "{slug}");
441 +
        assert!(page.body.contains(&marked(slug)), "{slug} is marked: {}", page.body);
442 +
        for other in maintainers.iter().chain(admins.iter()).filter(|o| *o != slug) {
443 +
            assert!(!page.body.contains(&marked(other)), "{slug} marks {other}");
444 +
        }
445 +
    }
446 +
    let runs = get(app, "/andy/demo/settings/runs", Some(&andy)).await.body;
447 +
    assert!(runs.contains("id=\"flow-priority\"") && runs.contains("id=\"flow-supersede\"") && !runs.contains("id=\"general\""));
448 +
    // Storage is only for a repository kept in a bucket.
449 +
    assert_eq!(get(app, "/andy/demo/settings/storage", Some(&andy)).await.status, StatusCode::NOT_FOUND);
450 +
    assert!(!general.body.contains("/andy/demo/settings/storage\""));
451 +
    assert_eq!(get(app, "/andy/demo/settings/nope", Some(&andy)).await.status, StatusCode::NOT_FOUND);
452 +
453 +
    // A maintainer has the maintainers' pages only; the admins' are not
454 +
    // listed and not found.
455 +
    let page = get(app, "/andy/demo/settings", Some(&bob)).await;
456 +
    assert_eq!(page.status, StatusCode::OK);
457 +
    for slug in maintainers {
458 +
        assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&bob)).await.status, StatusCode::OK, "{slug}");
459 +
        assert!(page.body.contains(&format!("data-slug=\"{slug}\"")), "{slug} listed");
460 +
    }
461 +
    for slug in admins {
462 +
        assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&bob)).await.status, StatusCode::NOT_FOUND, "{slug}");
463 +
        assert!(!page.body.contains(&format!("data-slug=\"{slug}\"")), "{slug} not listed");
464 +
    }
465 +
    assert!(!page.body.contains("<h2>Danger zone</h2>"), "an empty group is left out");
466 +
    let bob_csrf = csrf_of(&page.body);
467 +
    let r = post(app, "/andy/demo/settings/flow-priority", Some(&bob), &format!("_csrf={bob_csrf}&priority=normal")).await;
468 +
    let back = settled(app, r, Some(&bob), "/andy/demo/settings/runs").await;
469 +
    assert!(back.body.contains("Its jobs are normal priority.") && back.body.contains(&marked("runs")), "{}", back.body);
470 +
    // A writer has none of them.
471 +
    assert_eq!(get(app, "/andy/demo/settings", Some(&carol)).await.status, StatusCode::NOT_FOUND);
472 +
    for slug in ["general", "push-protection", "advanced"] {
473 +
        assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&carol)).await.status, StatusCode::NOT_FOUND, "{slug}");
474 +
    }
475 +
476 +
    // Each form returns to its own page, with what happened.
477 +
    for (path, form, page, says) in [
478 +
        ("", "description=Demo&default_bookmark=main&visibility=public&topics=", "general", "Saved."),
479 +
        ("/requirements", "required_approvals=1", "landing", "Landing requirements saved."),
480 +
        ("/checks", "required_checks=build", "checks", "Required checks saved."),
481 +
        ("/push-protection", "", "push-protection", "Push protection is off"),
482 +
        ("/push-protection", "protect=on", "push-protection", "Push protection is on"),
483 +
        ("/secret-bypass", "fingerprint=0123456789abcdef&reason=a+test+fixture", "push-protection", "is allowed here"),
484 +
        ("/flow", "flow=sometimes", "flow", "Pick on, own runners, off or the site default."),
485 +
        ("/flow-priority", "priority=low", "runs", "Its jobs are low priority."),
486 +
        ("/flow-supersede", "running=on", "runs", "cancels the older runs it supersedes, queued or running"),
487 +
        ("/agent-gates", "agents=on", "gates", "may now approve or reject"),
488 +
        ("/variables", "action=set&name=RUST_LOG&value=debug", "variables", "RUST_LOG set."),
489 +
        ("/coverage", "min=80&max_drop=", "coverage", "Coverage rules saved"),
490 +
        ("/hooks", "action=delete&id=999", "webhooks", "Webhook deleted."),
491 +
        ("/mirror", "", "git-mirror", "The Git mirror is off."),
492 +
        ("/diffusion", "diffusion=off&land=propose", "diffusion", "Diffusion is off"),
493 +
        ("/backup", "backup=off", "backup", "left out of its owner"),
494 +
        ("/archive", "action=neither", "advanced", "Unknown action."),
495 +
    ] {
496 +
        let r = post(app, &format!("/andy/demo/settings{path}"), Some(&andy), &format!("_csrf={csrf}&{form}")).await;
497 +
        let back = settled(app, r, Some(&andy), &format!("/andy/demo/settings/{page}")).await;
498 +
        assert_eq!(back.status, StatusCode::OK, "{path}");
499 +
        assert!(back.body.contains(says) && back.body.contains(&marked(page)), "{path}: {}", back.body);
500 +
    }
501 +
502 +
    // A kept /settings#anchor link is sent on by hub.js, never inline.
503 +
    let js = get(app, "/static/hub.js", None).await.body;
504 +
    assert!(js.contains("[data-settings-anchors]") && js.contains("\"flow-priority\": \"runs\""), "{js}");
387 505
}
388 506
389 507
#[tokio::test(flavor = "multi_thread")]
…
109 unchanged lines
499 617
    assert!(db::hooks::hook(&app.db, repo_hook).await.unwrap().is_some(), "not the organisation's");
500 618
    assert_eq!(get(app, &format!("/orgs/acme/settings/hooks/{repo_hook}/deliveries"), Some(&andy)).await.status, StatusCode::NOT_FOUND);
501 619
    // The repository's own settings still manage its hooks (shared code).
502 -
    let rs = get(app, "/andy/demo/settings", Some(&andy)).await;
620 +
    let rs = get(app, "/andy/demo/settings/webhooks", Some(&andy)).await;
503 621
    assert!(rs.body.contains("https://hooks.invalid/repo") && rs.body.contains("action=\"/andy/demo/settings/hooks\""));
504 622
505 623
    // Storage: sizes and loose objects, and maintenance packs them.
…
319 unchanged lines
825 943
    assert!(r.body.contains("DeepSeek added and answered"), "{}", r.body);
826 944
    let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap();
827 945
    assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}");
828 -
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
946 +
    let repo_page = get(app, "/acme/plans/settings/assistants", Some(&andy)).await.body;
829 947
    assert!(repo_page.contains("from the acme organisation") && repo_page.contains("From the acme organisation"), "{repo_page}");
830 948
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
831 949
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude", "DeepSeek"]);
…
4 unchanged lines
836 954
    let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await;
837 955
    assert!(r.body.contains("Claude is off."), "{}", r.body);
838 956
    let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await;
957 +
    let r = settled(app, r, Some(&andy), "/acme/plans/settings/assistants").await;
839 958
    assert!(r.body.contains("Claude added and answered"), "{}", r.body);
840 959
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
841 960
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]);
842 -
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
961 +
    let repo_page = get(app, "/acme/plans/settings/assistants", Some(&andy)).await.body;
843 962
    assert!(repo_page.contains("off above"), "{repo_page}");
844 963
    post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await;
845 964
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
…
759 unchanged lines
modified crates/hub/tests/api.rs +27 −15
…
60 unchanged lines
61 61
    status: StatusCode,
62 62
    body: String,
63 63
    cookie: Option<String>,
64 +
    location: Option<String>,
64 65
}
65 66
66 67
async fn call(
…
30 unchanged lines
97 98
        .and_then(|v| v.split(';').next())
98 99
        .and_then(|kv| kv.split_once('='))
99 100
        .map(|(_, v)| v.to_string());
101 +
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
100 102
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
101 -
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
103 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
102 104
}
103 105
106 +
/// A repository settings form's answer (#278): a redirect to the page that
107 +
/// holds the form, followed.
108 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
109 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
110 +
    let to = r.location.expect("a redirect");
111 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
112 +
    call(app, "GET", &to, None, cookie, None, None).await
113 +
}
114 +
104 115
async fn api(app: &Shared, method: &str, path: &str, bearer: Option<&str>, json: Option<&str>) -> (StatusCode, serde_json::Value) {
105 116
    let r = call(app, method, path, bearer, None, json, None).await;
106 117
    let v = serde_json::from_str(&r.body).unwrap_or(serde_json::Value::String(r.body.clone()));
…
222 unchanged lines
329 340
    // Require the "build" check.
330 341
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
331 342
    let csrf = csrf_of(&settings.body);
332 -
    assert_eq!(
333 -
        call(app, "POST", "/andy/demo/settings/checks", None, Some(&andy), None, Some(&format!("_csrf={csrf}&required_checks=build")))
334 -
            .await
335 -
            .status,
336 -
        StatusCode::OK
337 -
    );
343 +
    let r = call(app, "POST", "/andy/demo/settings/checks", None, Some(&andy), None, Some(&format!("_csrf={csrf}&required_checks=build")))
344 +
        .await;
345 +
    assert!(settled(app, r, Some(&andy), "/andy/demo/settings/checks").await.body.contains("Required checks saved."));
338 346
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
339 347
    assert_eq!(v["landable"], false);
340 348
    assert!(v["blockers"][0].as_str().unwrap().contains("build"), "{v}");
…
403 unchanged lines
744 752
    let csrf = csrf_of(&settings);
745 753
    let form = |f: String| {
746 754
        let andy = andy.clone();
747 -
        async move { call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await.body }
755 +
        async move {
756 +
            let r = call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await;
757 +
            settled(app, r, Some(&andy), "/andy/demo/settings/webhooks").await.body
758 +
        }
748 759
    };
749 760
    let url = rx.url.replace(':', "%3A").replace('/', "%2F");
750 761
    assert!(form(format!("_csrf={csrf}&url={url}&format=discord&events=*")).await.contains("Webhook added"));
…
37 unchanged lines
788 799
    let v: serde_json::Value = serde_json::from_str(&got).unwrap();
789 800
    assert_eq!(v["embeds"][0]["title"], "OxygenHub is connected");
790 801
    assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]));
791 -
    let page = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body;
802 +
    let page = call(app, "GET", "/andy/demo/settings/webhooks", None, Some(&andy), None, None).await.body;
792 803
    assert!(page.contains("last delivery <span class=\"sig sig-valid\">✔</span> 200"), "the table shows how it went");
793 804
794 805
    // Editing: a blank URL and secret keep theirs; format, events and active change.
…
188 unchanged lines
983 994
        StatusCode::BAD_REQUEST
984 995
    );
985 996
    // The settings page shows the same list.
986 -
    assert!(call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test"));
997 +
    assert!(call(app, "GET", "/andy/demo/settings/checks", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test"));
987 998
}
988 999
989 1000
/// OxygenFlow per repository: site administrators turn it on or off in
…
19 unchanged lines
1009 1020
    let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
1010 1021
    assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos");
1011 1022
    let tab = call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await;
1012 -
    assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings#flow"));
1023 +
    assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings/flow"));
1013 1024
1014 1025
    let set = |value: &'static str| {
1015 1026
        let (app, andy) = (app.clone(), andy.clone());
1016 1027
        async move {
1017 -
            let settings = call(&app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
1018 -
            call(
1028 +
            let settings = call(&app, "GET", "/andy/demo/settings/flow", None, Some(&andy), None, None).await;
1029 +
            let r = call(
1019 1030
                &app,
1020 1031
                "POST",
1021 1032
                "/andy/demo/settings/flow",
…
2 unchanged lines
1024 1035
                None,
1025 1036
                Some(&format!("_csrf={}&flow={value}", csrf_of(&settings.body))),
1026 1037
            )
1027 -
            .await
1038 +
            .await;
1039 +
            settled(&app, r, Some(&andy), "/andy/demo/settings/flow").await
1028 1040
        }
1029 1041
    };
1030 1042
    // A repository admin who is not a site admin sees the state but cannot change it.
1031 1043
    let id = oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1032 1044
    oxygenhub::db::accounts::set_admin(&app.db, id, false).await.unwrap();
1033 -
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
1045 +
    let settings = call(app, "GET", "/andy/demo/settings/flow", None, Some(&andy), None, None).await;
1034 1046
    assert!(settings.body.contains("do not start runs") && !settings.body.contains("name=\"flow\""));
1035 1047
    let r = set("on").await;
1036 1048
    assert!(r.body.contains("Only site administrators"), "{}", r.body);
…
617 unchanged lines
modified crates/hub/tests/edits.rs +20 −5
…
83 unchanged lines
84 84
    }
85 85
}
86 86
87 +
/// A repository settings form's answer (#278): a redirect to the page that
88 +
/// holds the form, followed.
89 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
90 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
91 +
    let to = r.location.expect("a redirect");
92 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
93 +
    send(app, "GET", &to, cookie, None).await
94 +
}
95 +
87 96
fn csrf_of(page: &str) -> String {
88 97
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
89 98
    page[i..].split('"').next().unwrap().to_string()
…
150 unchanged lines
240 249
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
241 250
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
242 251
    // Nobody approved, and the repository requires no approval: the page says so and Land asks first.
243 -
    assert!(page.body.contains("settings</a> require none."), "{}", page.body);
252 +
    assert!(page.body.contains("/andy/demo/settings/landing\">landing rules</a> require none."), "{}", page.body);
244 253
    assert!(page.body.contains("without any approval?"), "{}", page.body);
245 254
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
246 255
    // The action redirects back (so a reload never repeats it), saying so.
…
382 unchanged lines
629 638
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
630 639
631 640
    // The Advanced section, and a transfer with the wrong confirmation.
632 -
    let settings = send(app, "GET", "/andy/vk/settings", Some(&andy), None).await.body;
641 +
    let settings = send(app, "GET", "/andy/vk/settings/advanced", Some(&andy), None).await.body;
633 642
    assert!(
634 643
        settings.contains("Advanced") && settings.contains("Transfer ownership") && settings.contains("Delete wiki data"),
635 644
        "{settings}"
636 645
    );
637 646
    let csrf = csrf_of(&settings);
638 647
    let r = send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=wrong"))).await;
648 +
    let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await;
639 649
    assert!(r.body.contains("full name to confirm"), "{}", r.body);
640 650
    let r =
641 651
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=nobody&confirm=andy%2Fvk"))).await;
652 +
    let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await;
642 653
    assert!(r.body.contains("no user or organisation"), "{}", r.body);
643 654
    // Moved to the organisation: the new page works, the old URL redirects
644 655
    // with the rest of its path, and the client's old remote still pushes.
645 656
    let r =
646 657
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=andy%2Fvk"))).await;
647 -
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
658 +
    let moved = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
659 +
    assert!(moved.body.contains("Moved from andy/vk"), "{}", moved.body);
648 660
    let page = send(app, "GET", "/doomsday/vk", None, None).await;
649 661
    assert_eq!(page.status, StatusCode::OK);
650 662
    assert!(page.body.contains("a port"), "{}", page.body);
…
8 unchanged lines
659 671
    assert!(audit.iter().any(|a| a.action == "repo.transferred" && a.target == "andy/vk" && a.detail == "doomsday/vk"));
660 672
661 673
    // Archived: pushes, issues and comments are refused; reading works; back again.
662 -
    let settings = send(app, "GET", "/doomsday/vk/settings", Some(&andy), None).await.body;
674 +
    let settings = send(app, "GET", "/doomsday/vk/settings/advanced", Some(&andy), None).await.body;
663 675
    let csrf = csrf_of(&settings);
664 676
    let r =
665 677
        send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=archive&reason=Superseded")))
666 678
            .await;
679 +
    let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
667 680
    assert!(r.body.contains("is archived") && r.body.contains("Bring this repository back") && r.body.contains("Superseded"), "{}", r.body);
668 681
    assert!(send(app, "GET", "/doomsday/vk", None, None).await.body.contains(">archived<"));
669 682
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
…
4 unchanged lines
674 687
    let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await;
675 688
    assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body);
676 689
    let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await;
690 +
    let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await;
677 691
    assert!(r.body.contains("The repository is back"), "{}", r.body);
678 692
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
679 693
}
…
126 unchanged lines
806 820
    let r = send(app, "POST", "/andy/demo/wiki/Guide/delete", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
807 821
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
808 822
    assert!(send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body.contains("no page called"));
809 -
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None).await.body;
823 +
    let settings = send(app, "GET", "/andy/demo/settings/advanced", Some(&andy), None).await.body;
810 824
    assert!(settings.contains("DELETE WIKI DATA"), "{settings}");
811 825
    let r = send(
812 826
        app,
…
3 unchanged lines
816 830
        Some(&format!("_csrf={}&confirm=andy%2Fdemo", csrf_of(&settings))),
817 831
    )
818 832
    .await;
833 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/advanced").await;
819 834
    assert!(r.body.contains("The wiki is in the trash"), "{}", r.body);
820 835
    assert!(send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await.body.contains("No wiki yet"));
821 836
}
…
49 unchanged lines
modified crates/hub/tests/flow.rs +39 −15
…
1589 unchanged lines
1590 1590
        let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
1591 1591
        page[i..].split('"').next().unwrap().to_string()
1592 1592
    };
1593 -
    let (st, page) = web("GET", "/andy/demo/settings".into(), None).await;
1593 +
    let (st, page) = web("GET", "/andy/demo/settings/runners".into(), None).await;
1594 1594
    assert_eq!(st, StatusCode::OK);
1595 -
    assert!(page.contains("<h2>Runners</h2>"), "the owner sees the section");
1595 +
    assert!(page.contains("<h2>Runners</h2>"), "the owner sees the page");
1596 1596
    let (_, page) = web("POST", "/andy/demo/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await;
1597 1597
    let i = page.find("--token ").unwrap() + "--token ".len();
1598 1598
    let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect();
…
7 unchanged lines
1606 1606
    // The site's runner (hub.toml, from its first claim) serves it too, but is not the owner's to remove.
1607 1607
    claim(&h.app).await;
1608 1608
    let site = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "test-1").unwrap();
1609 -
    let (_, page) = web("GET", "/andy/demo/settings".into(), None).await;
1609 +
    let (_, page) = web("GET", "/andy/demo/settings/runners".into(), None).await;
1610 1610
    assert!(page.contains("<strong>mine</strong>") && page.contains("this repository"), "{page}");
1611 1611
    assert!(page.contains("<strong>test-1</strong>") && page.contains("the whole site"), "{page}");
1612 1612
    let csrf = csrf_of(&page);
1613 1613
    let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", site.id), Some(format!("_csrf={csrf}"))).await;
1614 1614
    assert_eq!(st, StatusCode::NOT_FOUND, "not the repository's own");
1615 -
    let (_, page) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await;
1616 -
    assert!(page.contains("mine removed"), "{page}");
1615 +
    let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await;
1616 +
    assert_eq!(st, StatusCode::SEE_OTHER, "back to the Runners page");
1617 +
    let (_, page) = web("GET", "/andy/demo/settings/runners?notice=x".into(), None).await;
1618 +
    assert!(!page.contains("<strong>mine</strong>"), "{page}");
1617 1619
    assert!(db::runners::get(&h.app.db, mine.id).await.unwrap().is_none());
1618 1620
1619 1621
    // The same for an organisation's owner.
…
454 unchanged lines
2074 2076
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
2075 2077
        }
2076 2078
        let req = req.body(Body::from(form.unwrap_or_default())).unwrap();
2077 -
        let app = app.clone();
2079 +
        let (app, cookie) = (app.clone(), cookie.clone());
2078 2080
        async move {
2079 -
            let res = oxygenhub::router(app).oneshot(req).await.unwrap();
2081 +
            let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
2082 +
            // A repository settings form returns to its page (#278).
2083 +
            let back =
2084 +
                res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from);
2085 +
            if let Some(to) = back {
2086 +
                let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap();
2087 +
                res = oxygenhub::router(app).oneshot(req).await.unwrap();
2088 +
            }
2080 2089
            let status = res.status();
2081 2090
            let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
2082 2091
            (status, body)
…
10 unchanged lines
2093 2102
    variables::set(&app.db, Scope::Site, "LANG", "C").await.unwrap();
2094 2103
    let org = db::accounts::org_by_name(&app.db, "acme").await.unwrap().unwrap();
2095 2104
    variables::set(&app.db, Scope::Org(org.id), "CC", "clang").await.unwrap();
2096 -
    let (st, settings) = page("/acme/app/settings", None).await;
2105 +
    let (st, settings) = page("/acme/app/settings/variables", None).await;
2097 2106
    assert_eq!(st, StatusCode::OK, "{settings}");
2098 2107
    assert!(settings.contains("Inherited") && settings.contains("the acme organisation") && settings.contains("overridden"), "{settings}");
2099 2108
    let csrf = csrf_of(&settings);
…
54 unchanged lines
2154 2163
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
2155 2164
        }
2156 2165
        let req = req.body(Body::from(form.unwrap_or_default())).unwrap();
2157 -
        let app = app.clone();
2166 +
        let (app, cookie) = (app.clone(), cookie.clone());
2158 2167
        async move {
2159 -
            let res = oxygenhub::router(app).oneshot(req).await.unwrap();
2168 +
            let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
2169 +
            // A repository settings form returns to its page (#278).
2170 +
            let back =
2171 +
                res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from);
2172 +
            if let Some(to) = back {
2173 +
                let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap();
2174 +
                res = oxygenhub::router(app).oneshot(req).await.unwrap();
2175 +
            }
2160 2176
            let status = res.status();
2161 2177
            let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
2162 2178
            (status, body)
…
12 unchanged lines
2175 2191
    assert!(body.contains("deploy-token set.") && !body.contains("site-level-value"), "{body}");
2176 2192
    let (_, body) = page("/admin/secrets", Some(format!("_csrf={csrf}&action=set&name=bad+name&value=x"))).await;
2177 2193
    assert!(body.contains("name is 1 to 64 of"), "{body}");
2178 -
    let (_, settings) = page("/andy/demo/settings", None).await;
2194 +
    let (_, settings) = page("/andy/demo/settings/secrets", None).await;
2179 2195
    assert!(
2180 2196
        settings.contains("Secrets") && settings.contains("<code>deploy-token</code>") && settings.contains("the site</td>"),
2181 2197
        "{settings}"
…
2170 unchanged lines
4352 4368
    let web = |method: &'static str, path: &str, body: Option<String>| {
4353 4369
        let (app, cookie, path) = (h.app.clone(), bob.clone(), path.to_string());
4354 4370
        async move {
4355 -
            let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie);
4371 +
            let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie.clone());
4356 4372
            if body.is_some() {
4357 4373
                req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
4358 4374
            }
4359 -
            let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap();
4375 +
            let req = req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap();
4376 +
            let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
4377 +
            // A repository settings form returns to its page (#278).
4378 +
            let back =
4379 +
                res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from);
4380 +
            if let Some(to) = back {
4381 +
                let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap();
4382 +
                res = oxygenhub::router(app).oneshot(req).await.unwrap();
4383 +
            }
4360 4384
            String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
4361 4385
        }
4362 4386
    };
…
2 unchanged lines
4365 4389
        page[i..].split('"').next().unwrap().to_string()
4366 4390
    };
4367 4391
    // No runner of his own yet: he is told how, and cannot switch it on.
4368 -
    let page = web("GET", "/bob/game/settings", None).await;
4392 +
    let page = web("GET", "/bob/game/settings/flow", None).await;
4369 4393
    assert!(page.contains("register a runner for this repository") && !page.contains("value=\"own\""), "{page}");
4370 4394
    let page = web("POST", "/bob/game/settings/flow", Some(format!("_csrf={}&flow=own", csrf_of(&page)))).await;
4371 4395
    assert!(page.contains("Register a runner for this repository"), "{page}");
…
9 unchanged lines
4381 4405
    assert_eq!(st, StatusCode::OK, "{body}");
4382 4406
    let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string();
4383 4407
    // The site's runners stay a site administrator's to grant; his own he may choose.
4384 -
    let page = web("GET", "/bob/game/settings", None).await;
4408 +
    let page = web("GET", "/bob/game/settings/flow", None).await;
4385 4409
    assert!(page.contains("value=\"own\"") && !page.contains("value=\"on\""), "{page}");
4386 4410
    let page = web("POST", "/bob/game/settings/flow", Some(format!("_csrf={}&flow=on", csrf_of(&page)))).await;
4387 4411
    assert!(page.contains("Only site administrators let the site's runners"), "{page}");
…
1680 unchanged lines
modified crates/hub/tests/history_scan.rs +24 −8
…
70 unchanged lines
71 71
    status: StatusCode,
72 72
    body: String,
73 73
    cookie: Option<String>,
74 +
    location: Option<String>,
74 75
}
75 76
76 77
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
…
17 unchanged lines
94 95
        .and_then(|v| v.split(';').next())
95 96
        .and_then(|kv| kv.split_once('='))
96 97
        .map(|(_, v)| v.to_string());
98 +
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
97 99
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
98 -
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
100 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
99 101
}
100 102
103 +
/// A repository settings form's answer (#278): a redirect to the page that
104 +
/// holds the form, followed.
105 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
106 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
107 +
    let to = r.location.expect("a redirect");
108 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
109 +
    send(app, "GET", &to, cookie, None).await
110 +
}
111 +
101 112
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
102 113
    send(app, "GET", path, cookie, None).await
103 114
}
…
100 unchanged lines
204 215
    assert!(p.idle && p.changes == 0, "{p:?}");
205 216
206 217
    // Maintainers see it under Found in history, never the secret.
207 -
    let page = get(app, "/andy/demo/settings", Some(&andy)).await;
218 +
    let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
208 219
    assert!(page.body.contains("Found in history") && page.body.contains(&fp), "{}", page.body);
209 220
    assert!(page.body.contains(&format!("/andy/demo/change/{}", leak.to_hex())) && page.body.contains("deploy/pay.env:2"));
210 221
    assert!(!page.body.contains(&key), "the settings page shows no secret");
…
22 unchanged lines
233 244
    // A writer can neither resolve nor remove an allowed fingerprint.
234 245
    let bob = register(app, "bob").await;
235 246
    let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
236 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
247 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await;
248 +
    assert!(r.body.contains("bob now has write access."), "{}", r.body);
237 249
    let bob_csrf = csrf_of(&get(app, "/andy/demo", Some(&bob)).await.body);
238 250
    let stripe_id = found.iter().find(|f| f.fingerprint == fp).unwrap().id;
239 251
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&bob), &format!("_csrf={bob_csrf}&finding={stripe_id}&reason=x")).await;
…
3 unchanged lines
243 255
244 256
    // Resolved: the key was revoked. A reason is needed; audited.
245 257
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=")).await;
258 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
246 259
    assert!(r.body.contains("Say how it was resolved"), "{}", r.body);
247 260
    let r = post(
248 261
        app,
…
2 unchanged lines
251 264
        &format!("_csrf={csrf}&finding={stripe_id}&reason=revoked+in+the+Stripe+dashboard"),
252 265
    )
253 266
    .await;
254 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
267 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
255 268
    assert!(r.body.contains("marked resolved") && r.body.contains("revoked in the Stripe dashboard"), "{}", r.body);
256 269
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
257 270
    assert!(open.iter().all(|f| f.id != stripe_id), "{open:?}");
258 271
    let resolved = db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap();
259 272
    assert!(resolved.iter().any(|f| f.id == stripe_id && f.resolved_by == "andy" && f.resolved_ms.is_some()), "{resolved:?}");
260 273
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=again")).await;
274 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
261 275
    assert!(r.body.contains("not open here"), "only once");
262 276
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
263 277
    assert!(
…
4 unchanged lines
268 282
    // Allowed from its history row (a test token): no longer open.
269 283
    let r =
270 284
        post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}&reason=a+test+fixture")).await;
271 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
285 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
286 +
    assert!(r.body.contains(&format!("Fingerprint {gfp} is allowed here")), "{}", r.body);
272 287
    assert!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().is_empty());
273 288
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
274 289
    assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&gfp) && a.detail.contains("github-token")), "{audit:?}");
…
4 unchanged lines
279 294
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={gfp}")).await;
280 295
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
281 296
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
282 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
297 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
283 298
    assert!(r.body.contains(&format!("Fingerprint {gfp} is no longer allowed")), "{}", r.body);
284 299
    assert!(db::push_protection::allowed(&app.db, row(app).await.id).await.unwrap().is_empty());
285 300
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
…
4 unchanged lines
290 305
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
291 306
    assert!(open.iter().any(|f| f.fingerprint == gfp), "{open:?}");
292 307
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
308 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
293 309
    assert!(r.body.contains("is not allowed here"), "{}", r.body);
294 310
    write(work.path(), "ci2.env", format!("GH_TOKEN={ghp}\n").as_bytes());
295 311
    client.snapshot("t").unwrap();
…
7 unchanged lines
303 319
    // Scan history now: the whole history again, audited; nothing found
304 320
    // twice, and what was resolved stays resolved.
305 321
    let r = post(app, "/andy/demo/settings/secret-scan", Some(&andy), &format!("_csrf={csrf}")).await;
306 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
322 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
307 323
    assert!(r.body.contains("will be scanned again"), "{}", r.body);
308 324
    assert!(db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap().requested_ms.is_some());
309 325
    let p = pass(app, &row(app).await, budget).await.unwrap();
…
104 unchanged lines
414 430
        }
415 431
        tokio::time::sleep(Duration::from_millis(100)).await;
416 432
    }
417 -
    let page = get(app, "/andy/demo/settings", Some(&andy)).await;
433 +
    let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
418 434
    assert!(page.body.contains("Scanned up to the bookmarks") && page.body.contains("SCAN HISTORY NOW"), "{}", page.body);
419 435
}
modified crates/hub/tests/issues.rs +14 −1
…
104 unchanged lines
105 105
    r.cookie.unwrap()
106 106
}
107 107
108 +
/// A repository settings form's answer (#278): a redirect to the page that
109 +
/// holds the form, followed.
110 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
111 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
112 +
    let to = r.location.expect("a redirect");
113 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
114 +
    get(app, &to, cookie).await
115 +
}
116 +
108 117
fn csrf_of(page: &str) -> String {
109 118
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
110 119
    page[i..].split('"').next().unwrap().to_string()
…
631 unchanged lines
742 751
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"),
743 752
    )
744 753
    .await;
754 +
    let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await;
745 755
    assert!(r.body.contains("is not a topic"), "{}", r.body);
746 756
    let r = post(
747 757
        app,
…
2 unchanged lines
750 760
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"),
751 761
    )
752 762
    .await;
763 +
    let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await;
753 764
    assert!(r.body.contains("Saved"), "{}", r.body);
754 765
    assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502");
755 766
    let page = get(app, "/andy/gamma", None).await;
…
356 unchanged lines
1112 1123
    assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}");
1113 1124
    assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups"));
1114 1125
    // A repository chooses its own (BP3): its owners decide.
1115 -
    let game = get(app, "/bob/game/settings", Some(&bob)).await;
1126 +
    let game = get(app, "/bob/game/settings/backup", Some(&bob)).await;
1116 1127
    assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body);
1117 1128
    let csrf = csrf_of(&game.body);
1118 1129
    let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await;
1130 +
    let saved = settled(app, saved, Some(&bob), "/bob/game/settings/backup").await;
1119 1131
    assert!(saved.body.contains("left out of its owner"), "{}", saved.body);
1120 1132
    assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off");
1121 1133
    let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await;
1134 +
    let bad = settled(app, bad, Some(&bob), "/bob/game/settings/backup").await;
1122 1135
    assert!(bad.body.contains("Pick how this repository is backed up."));
1123 1136
}
1124 1137
…
75 unchanged lines
modified crates/hub/tests/proposals.rs +2 −1
…
223 unchanged lines
224 224
        post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1"))
225 225
            .await
226 226
            .status,
227 -
        StatusCode::OK
227 +
        StatusCode::SEE_OTHER,
228 +
        "back to Settings › Landing rules"
228 229
    );
229 230
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
230 231
    assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body);
…
789 unchanged lines
modified crates/hub/tests/push_protection.rs +23 −8
…
63 unchanged lines
64 64
    status: StatusCode,
65 65
    body: String,
66 66
    cookie: Option<String>,
67 +
    location: Option<String>,
67 68
}
68 69
69 70
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
…
17 unchanged lines
87 88
        .and_then(|v| v.split(';').next())
88 89
        .and_then(|kv| kv.split_once('='))
89 90
        .map(|(_, v)| v.to_string());
91 +
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
90 92
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
91 -
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
93 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
92 94
}
93 95
96 +
/// A repository settings form's answer (#278): a redirect to the page that
97 +
/// holds the form, followed.
98 +
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
99 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
100 +
    let to = r.location.expect("a redirect");
101 +
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
102 +
    send(app, "GET", &to, cookie, None).await
103 +
}
104 +
94 105
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
95 106
    send(app, "GET", path, cookie, None).await
96 107
}
…
130 unchanged lines
227 238
        let h1 = client.commit(&format!("add {file}"), &[]).unwrap();
228 239
        let err = client.push("origin", false).err().unwrap_or_else(|| panic!("{file} was accepted")).to_string();
229 240
        assert!(err.contains("HTTP 422") && err.contains("push protection refused this push"), "{err}");
230 -
        assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings#push-protection", h.base)), "{err}");
241 +
        assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings/push-protection", h.base)), "{err}");
231 242
        assert!(!err.contains("hint:"), "no credential hint for a refusal: {err}");
232 243
        for (rule, secret, line) in &expect {
233 244
            let fp = fingerprint(rule, secret.as_bytes());
…
23 unchanged lines
257 268
        log.iter().any(|p| !p.ok && p.reference == "main" && p.reason.contains("push protection") && p.reason.contains("README.md:3")),
258 269
        "{log:?}"
259 270
    );
260 -
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
271 +
    let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
261 272
    assert!(settings.body.contains("id=\"push-protection\"") && settings.body.contains(&fp), "{}", settings.body);
262 273
    assert!(!settings.body.contains(&ghp), "the settings page shows no secret");
263 274
…
1 unchanged lines
265 276
    let bob = register(app, "bob").await;
266 277
    let csrf = csrf_of(&settings.body);
267 278
    let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
268 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
279 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await;
280 +
    assert!(r.body.contains("bob now has write access."), "{}", r.body);
269 281
    let bob_page = get(app, "/andy/demo", Some(&bob)).await;
270 282
    let bob_csrf = csrf_of(&bob_page.body);
271 283
    let r = post(app, "/andy/demo/settings/secret-bypass", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={fp}&reason=mine")).await;
…
2 unchanged lines
274 286
275 287
    // A maintainer allows it, with a reason, audited; the push then lands.
276 288
    let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=")).await;
289 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
277 290
    assert!(r.body.contains("Say why"), "a reason is needed");
278 291
    let r =
279 292
        post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=a+revoked+test+token"))
280 293
            .await;
281 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
294 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
282 295
    assert!(r.body.contains(&format!("Fingerprint {fp} is allowed")) && r.body.contains("a revoked test token"), "{}", r.body);
283 296
    let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap();
284 297
    assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&fp) && a.detail.contains("github-token")), "{audit:?}");
…
10 unchanged lines
295 308
    client.commit("pay", &[]).unwrap();
296 309
    let err = client.push("origin", false).err().unwrap().to_string();
297 310
    assert!(err.contains("an agent's token (Claude)") && err.contains("do not try to get around the refusal"), "{err}");
298 -
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
311 +
    let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await;
299 312
    assert!(settings.body.contains("via Claude"), "the finding names the agent");
300 313
301 314
    // With push protection off, the same push lands; the change is audited.
302 315
    let csrf = csrf_of(&settings.body);
303 316
    let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}")).await;
304 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
317 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
318 +
    assert!(r.body.contains("Push protection is off"), "{}", r.body);
305 319
    assert!(!db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection);
306 320
    let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap();
307 321
    assert!(audit.iter().any(|a| a.action == "repo.push_protection" && a.detail == "off"), "{audit:?}");
308 322
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
309 323
    let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}&protect=on")).await;
310 -
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
324 +
    let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await;
325 +
    assert!(r.body.contains("Push protection is on"), "{}", r.body);
311 326
    assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection);
312 327
}
313 328
…
32 unchanged lines
modified crates/hub/tests/releases.rs +3 −0
…
270 unchanged lines
271 271
    assert!(overview.contains("oxy clone ") && !overview.contains("git clone "), "no Git in the Code panel without the mirror");
272 272
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text();
273 273
    let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await;
274 +
    let back = r.location.clone().unwrap_or_default();
275 +
    assert!(back.starts_with("/andy/demo/settings/git-mirror?"), "{back}");
276 +
    let r = send(app, "GET", &back, Some(&andy), None, None, vec![]).await;
274 277
    assert!(r.text().contains("mirror is on"), "{}", r.text());
275 278
    // The Code panel then offers git clone too: oxy first, then git, then the downloads (#215).
276 279
    let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text();
…
95 unchanged lines
modified crates/hub/tests/storage.rs +5 −3
…
204 unchanged lines
205 205
        // The pages say where it lives; Settings → Storage checks the bucket.
206 206
        let page = send(&h.app, "GET", "/andy/cloudy", Some(&me), None).await.1;
207 207
        assert!(page.contains("class=\"store store-s3\"") && page.contains(url.split('?').next().unwrap()), "{page}");
208 -
        let settings = send(&h.app, "GET", "/andy/cloudy/settings", Some(&me), None).await.1;
208 +
        let settings = send(&h.app, "GET", "/andy/cloudy/settings/storage", Some(&me), None).await.1;
209 209
        assert!(settings.contains("id=\"storage\""), "{settings}");
210 210
        let csrf = csrf_of(&settings);
211 -
        let (_, after, _) =
211 +
        let (st, _, _) =
212 212
            send(&h.app, "POST", "/andy/cloudy/settings/storage", Some(&me), Some(&format!("_csrf={csrf}&action=check"))).await;
213 -
        assert!(after.contains("The bucket passed the check: 8 checks passed."), "{after}");
213 +
        assert_eq!(st, StatusCode::SEE_OTHER, "back to Settings › Storage");
214 +
        let after = send(&h.app, "GET", "/andy/cloudy/settings/storage", Some(&me), None).await.1;
215 +
        assert!(after.contains("8 checks passed"), "{after}");
214 216
        // A second repository cannot start in the same place.
215 217
        let (st, body) = new_repo(&h.app, &me, &fields.replace("name=cloudy", "name=again")).await;
216 218
        assert_eq!(st, StatusCode::OK);
…
53 unchanged lines
modified docs/spec.md +2 −2
…
276 unchanged lines
277 277
| `/{owner}/{name}/terraform` | Terraform state (§13.5.4): the backend block, how a pipeline and a person authenticate, and the states with their serials, last writer, lock and versions kept |
278 278
| `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) |
279 279
| `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) |
280 -
| `/{owner}/{name}/settings/*` | General, collaborators, bookmarks (default, delete), tokens, webhooks, mirror, danger zone |
280 +
| `/{owner}/{name}/settings/{page}` | A repository's settings (oxygenhub#278), one page per setting, with a sidebar of grouped pages beside it and the current one marked (`aria-current`); on a narrow screen the sidebar is a compact list above the page. **General**: General (`general`; `/settings` shows it), Collaborators (`collaborators`). **Code**: Landing rules (`landing`), Required checks (`checks`), Push protection (`push-protection`). **Flow**: Pipelines (`flow`: OxygenFlow on or off), Runs & priority (`runs`: priority, newest run wins), Gates & agents (`gates`), Runners (`runners`), Variables (`variables`), Secrets (`secrets`), Coverage (`coverage`). **Integrations**: Webhooks (`webhooks`), AI assistants (`assistants`), Git mirror (`git-mirror`), Diffusion (`diffusion`). **Data**: Storage (`storage`, a repository kept in a bucket only), Backup (`backup`). **Danger zone**: Advanced (`advanced`: transfer, archive, delete wiki data, delete). The repository's maintainers have the pages; Collaborators, Runners, Variables, Secrets, Webhooks, AI assistants, Storage and Advanced are its admins' only, left out of the sidebar and not found for others. An archived repository's settings are its admins' only. Each form posts to its own path, as before, and redirects to the page that holds it with a notice or an error; a runner's registration token is shown once on the Runners page. A kept link to the former one-page settings, `/settings#section`, opens the page that has that section (`hub.js`). Names such as *Settings › Push protection* are these pages |
281 281
| `/{owner}/{name}/edit/{ref}/{path}`, `/new/{ref}/{path}` | Web edit (§6.9) |
282 282
| `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme |
283 283
| `/notifications` | Inbox (§10) |
…
438 unchanged lines
722 722
- **Rules** are data, `crates/hub/src/push_protection.toml`, compiled in and versioned (`version`, shown in every refusal as `rules vN`). Each has an id, a description, a regex matched on bytes (its `secret` group, else the whole match, is the secret), optional keywords that must occur (case-insensitive) before the regex runs, and optionally a regex the same file or inserted text must also match, for secrets that are only one when paired. The first set: AWS access key IDs, AWS secret access keys next to a key ID, Google Cloud service account keys (`"type": "service_account"` with a `private_key`), Azure storage connection strings, GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), GitLab personal access tokens (`glpat-`), this hub's own credentials (personal access tokens `oxyhub_`, agent grant tokens `oxyagent_`, OxygenFlow job tokens `flowjob_`, runner credentials `flowrunner_` and registration tokens `flowreg_`), Slack tokens (`xoxb-` and kin), Stripe live keys (`sk_live_`, `rk_live_`), OpenSSH and PEM private keys (a `BEGIN … PRIVATE KEY` line, a body and its `END` line; the header alone, as in documentation, is not one). Rules are tried in order and a match overlapping an earlier rule's is dropped, so one secret is reported once, by its most specific rule.
723 723
- **Findings.** Each names its rule, the file and line (the line as the change that adds it leaves the file, worked out from the repository's state with the push's changes applied in memory, only when there is a finding), a fingerprint (the first 16 hex digits of BLAKE3 of the rule id, a NUL and the secret) and the line with the secret masked (its first four characters kept). The secret itself is never shown, logged or stored.
724 724
- **Budget**, so pushes stay fast: at most `scan_limit` bytes scanned per push request (16 MiB), each file or inserted text at most `scan_file_limit` (1 MiB; larger ones, and binary blobs, with a NUL in the first 8000 bytes, are skipped), and `scan_time_ms` (2000) in all, all under `[limits]` in `hub.toml`. Past the size or time budget the rest goes unscanned and the push is **accepted**: refusing would block every large import (a whole history pushed in parts, each part with its own budget) for what is most likely nothing. The accepted references' push log entries say how much went unscanned (`push protection scanned 16.0 MiB and stopped at its size budget: … more were not scanned`). History the hub already holds is the history scan's (below).
725 -
- **Refusal.** The push fails with HTTP `422`, nothing of it stored or moved, and a plain text the client prints as it is: one line per finding, `path:line: description (fingerprint F)` with its masked line under it (20 at most, then a count), then what to do: take the value out of the change that adds it (`oxy amend`, or `oxy abandon` a change that only adds it) and push again, revoke the credential if it was real, or have a maintainer allow the fingerprint at `{base_url}/{owner}/{name}/settings#push-protection`. A push through an agent's token (an agent-marked token or a keyless agent's grant) adds a paragraph for the agent naming it: remove the value and push again, do not work around the refusal, and tell its person if it was asked to commit the value, since only a maintainer can allow one. The refusal is recorded in the push log (`/{owner}/{name}/pushes`, each named reference refused with `push protection: N possible secrets (path:line, …)`), and its findings (rule, path, line, fingerprint, masked line, pusher and agent) in `secret_findings` (the newest 200 per repository) for the maintainers.
725 +
- **Refusal.** The push fails with HTTP `422`, nothing of it stored or moved, and a plain text the client prints as it is: one line per finding, `path:line: description (fingerprint F)` with its masked line under it (20 at most, then a count), then what to do: take the value out of the change that adds it (`oxy amend`, or `oxy abandon` a change that only adds it) and push again, revoke the credential if it was real, or have a maintainer allow the fingerprint at `{base_url}/{owner}/{name}/settings/push-protection`. A push through an agent's token (an agent-marked token or a keyless agent's grant) adds a paragraph for the agent naming it: remove the value and push again, do not work around the refusal, and tell its person if it was asked to commit the value, since only a maintainer can allow one. The refusal is recorded in the push log (`/{owner}/{name}/pushes`, each named reference refused with `push protection: N possible secrets (path:line, …)`), and its findings (rule, path, line, fingerprint, masked line, pusher and agent) in `secret_findings` (the newest 200 per repository) for the maintainers.
726 726
- **Bypass.** Under Settings › Push protection (maintain role) a maintainer sees the recent findings not yet allowed and allows one by its fingerprint with a reason (required, at most 500 characters), from the finding's row or by typing a fingerprint from a refusal: a row in `secret_bypasses` (repository, fingerprint, the rule it was found under, reason, who, when), audited as `secret.bypass`. A finding whose fingerprint is allowed passes; the allowed fingerprints are listed with their reasons. A writer cannot allow one. Each allowed fingerprint has a **remove** button (maintain role, `POST …/settings/secret-bypass/remove`), audited as `secret.bypass_removed` with the reason it had: pushes adding that value are refused again and its history findings are open again (oxygenhub#277).
727 727
- **Setting.** Per repository, `repositories.push_protection`, on by default; Settings › Push protection (maintain role) turns it off or on, audited as `repo.push_protection`. A wiki follows its repository's setting and bypasses (migration 0085, both databases).
728 728
- **History scan** (oxygenhub#277). The same rules run over what each repository already holds, so a secret committed before push protection existed, or while it was off, is found too. What is scanned is every change reachable from the repository's bookmarks, read as push protection reads a push: the lines each change inserts and the text blobs it puts in place of a file's content (within `scan_file_limit`; binary and lazy blobs are skipped). It is the worker role's `secrets.history_scan` task (docs/roles.md), under its leader lock: ten seconds after the process starts it passes over every repository (so the first deploy scans every repository's whole history), then over a repository again when a push to it is announced on the bus (`repo:N`, `push`), when a maintainer asks or turns push protection back on, and every 15 minutes over all of them for what the bus missed (a landing, a lost event). Repositories with push protection off are skipped; wikis are not scanned.
…
138 unchanged lines
modified templates/deliveries.html +1 −1
…
4 unchanged lines
5 5
{% block mainclass %} class="wide"{% endblock %}
6 6
{% block content %}
7 7
<div class="head"><h1>Deliveries <small><code class="hook-url">{{ crate::db::hooks::masked_url(hook.url) }}</code> · {{ hook.format }} · events {{ hook.events }}</small></h1></div>
8 -
<p><a href="{{ settings_url }}#webhooks">← settings</a></p>
8 +
<p><a href="{{ back_url }}">← settings</a></p>
9 9
{% if rows.is_empty() %}<p class="empty">Nothing delivered yet.</p>{% else %}
10 10
<div class="table"><table>
11 11
<tr><th>When</th><th>Event</th><th>Delivery</th><th>Attempts</th><th>Status</th><th>Response</th><th></th></tr>
…
9 unchanged lines
modified templates/flow_runs.html +1 −1
…
13 unchanged lines
14 14
  {% if enabled %}
15 15
  <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs. Once it is there, <strong>Run pipeline</strong> above runs a bookmark's current state without a push, as does the button beside each bookmark and on each change.</p>
16 16
  {% else %}
17 -
  <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings#flow">Settings → OxygenFlow</a>.</p>
17 +
  <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings/flow">Settings › Pipelines</a>.</p>
18 18
  {% endif %}
19 19
<pre class="snippet">schema-version 1
20 20
…
32 unchanged lines
modified templates/proposal.html +1 −1
…
188 unchanged lines
189 189
  {% else if p.state == "closed" %}<p class="mute">closed</p>
190 190
  {% else %}
191 191
    {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p>
192 -
    {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings">settings</a> require none.</p>{% endif %}
192 +
    {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings/landing">landing rules</a> require none.</p>{% endif %}
193 193
    {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
194 194
    {% if can_land %}
195 195
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} data-confirm="Land #{{ p.number }} without any approval?"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form>
…
70 unchanged lines
modified templates/repo_settings.html +34 −25
…
3 unchanged lines
4 4
{% import "variables_panel.html" as va %}
5 5
{% import "secrets_panel.html" as se %}
6 6
{% import "repo_tabs.html" as m %}
7 -
{% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %}
7 +
{% block title %}{% if section != Section::General %}{{ section.title() }} · {% endif %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %}
8 8
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
9 9
{% block tabs %}{% call m::tabs(repo, "settings") %}{% endblock %}
10 10
{% block content %}
11 11
<div class="head"><h1>Settings <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div>
12 +
<div class="settings-layout">
13 +
<nav class="settings-nav" aria-label="Settings"{% if section == Section::General %} data-settings-anchors{% endif %}>
14 +
  {% for (group, pages) in nav %}<div class="settings-group">
15 +
    <h2>{{ group }}</h2>
16 +
    <ul>{% for p in pages %}<li><a href="{{ repo.url }}/settings/{{ p.slug() }}" data-slug="{{ p.slug() }}"{% if *p == section %} class="on" aria-current="page"{% endif %}>{{ p.title() }}</a></li>{% endfor %}</ul>
17 +
  </div>{% endfor %}
18 +
</nav>
19 +
<div class="sections settings-body">
12 20
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
13 21
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
14 -
<div class="sectioned">
15 -
<nav class="section-nav" aria-label="Sections">
16 -
  <a href="#general">General</a>
17 -
  <a href="#landing">Landing requirements</a>
18 -
  <a href="#push-protection">Push protection</a>
19 -
  <a href="#git-mirror">Git mirror</a>
20 -
  <a href="#checks">Required checks</a>
21 -
  <a href="#flow">OxygenFlow</a>
22 -
  <a href="#coverage">Coverage</a>
23 -
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
24 -
  <a href="#variables">Variables</a>
25 -
  <a href="#secrets">Secrets</a>
26 -
  <a href="#assistants">AI assistants</a>
27 -
  {% if storage.is_some() %}<a href="#storage">Storage</a>{% endif %}
28 -
  <a href="#collaborators">Collaborators</a>
29 -
  <a href="#advanced">Advanced</a>{% endif %}
30 -
</nav>
31 -
<div class="sections">
22 +
{% match section %}
23 +
{% when Section::General %}
32 24
<form class="card wide" id="general" method="post" action="{{ repo.url }}/settings">
33 25
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
34 26
  <h2>General</h2>
…
9 unchanged lines
44 36
  </div>
45 37
  <button type="submit">SAVE</button>
46 38
</form>
39 +
{% when Section::Landing %}
47 40
<form class="card wide" id="landing" method="post" action="{{ repo.url }}/settings/requirements">
48 41
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
49 42
  <h2>Landing requirements</h2>
…
4 unchanged lines
54 47
  </div>
55 48
  <button type="submit">SAVE</button>
56 49
</form>
50 +
{% when Section::PushProtection %}
57 51
<section class="panel" id="push-protection">
58 52
  <h2>Push protection</h2>
59 53
  <p>A push that adds what looks like a credential (cloud keys, tokens, private keys) is refused before any of it is stored, naming the file, line and a fingerprint for each. Only what a push adds is scanned, up to a budget; the push log says when a large push was not scanned in full. What the repository already holds is scanned in the background (Found in history, below).</p>
…
55 unchanged lines
115 109
  {% endfor %}
116 110
  </table>{% endif %}
117 111
</section>
112 +
{% when Section::GitMirror %}
118 113
<form class="card wide" id="git-mirror" method="post" action="{{ repo.url }}/settings/mirror">
119 114
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
120 115
  <h2>Git mirror</h2>
…
1 unchanged lines
122 117
  <p class="small mute">For tools that only speak Git (CI runners, IDEs): <code>git clone {{ shell.base_url }}{{ repo.url }}.git</code>. Private repositories take a personal access token as the password. Pushing to the mirror is refused.{% if row.git_mirror && !row.git_mirror_state.is_empty() %} Last exported state <code>{{ row.git_mirror_state|short }}</code>.{% endif %}</p>
123 118
  <button type="submit">SAVE</button>
124 119
</form>
120 +
{% when Section::Checks %}
125 121
<form class="card wide" id="checks" method="post" action="{{ repo.url }}/settings/checks">
126 122
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
127 123
  <h2>Required checks</h2>
…
1 unchanged lines
129 125
  <p class="small mute">Any token with <code>checks:write</code> reports checks with <code>POST {{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/checks</code>.</p>
130 126
  <button type="submit">SAVE</button>
131 127
</form>
128 +
{% when Section::Pipelines %}
132 129
<form class="card wide" id="flow" method="post" action="{{ repo.url }}/settings/flow">
133 130
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
134 131
  <h2>OxygenFlow</h2>
…
5 unchanged lines
140 137
    <label><input type="radio" name="flow" value="off"{% if row.flow == "off" %} checked{% endif %}> off</label>
141 138
    {% if site_admin %}<label><input type="radio" name="flow" value="default"{% if row.flow.is_empty() %} checked{% endif %}> site default ({% if flow_default %}on{% else %}off{% endif %}, from <code>[flow] repos</code> in <code>hub.toml</code>)</label>{% endif %}
142 139
  </div>
143 -
  <p class="small mute"><strong>Own runners only</strong>: jobs go only to runners registered for this repository or its owner (Runners, below), never to the site's. Only site administrators let the site's runners take them. It takes effect at the next push.</p>
140 +
  <p class="small mute"><strong>Own runners only</strong>: jobs go only to runners registered for this repository or its owner (Runners), never to the site's. Only site administrators let the site's runners take them. It takes effect at the next push.</p>
144 141
  <button type="submit">SAVE</button>
145 142
  {% else %}
146 -
  <p class="small mute">To run pipelines on your own machines, register a runner for this repository under <a href="#runners">Runners</a>, then choose <strong>on, own runners only</strong> here. A site administrator can also let the site's runners take its jobs.</p>
143 +
  <p class="small mute">To run pipelines on your own machines, register a runner for this repository under {% if role == crate::auth::Role::Admin %}<a href="{{ repo.url }}/settings/runners">Runners</a>{% else %}Runners{% endif %}, then choose <strong>on, own runners only</strong> here. A site administrator can also let the site's runners take its jobs.</p>
147 144
  {% endif %}
148 145
</form>
146 +
{% when Section::Runs %}
149 147
<form class="card wide" id="flow-priority" method="post" action="{{ repo.url }}/settings/flow-priority">
150 148
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
151 149
  <h2>OxygenFlow priority</h2>
…
13 unchanged lines
165 163
  <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label>
166 164
  <button type="submit">SAVE</button>
167 165
</form>
166 +
{% when Section::Gates %}
168 167
<form class="card wide" id="agent-gates" method="post" action="{{ repo.url }}/settings/agent-gates">
169 168
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
170 169
  <h2>Approval gates and agents</h2>
…
1 unchanged lines
172 171
  <label class="check"><input type="checkbox" name="agents"{% if row.agents_decide_gates %} checked{% endif %}> Let agents' tokens approve and reject this repository's approval gates (each decision still says which agent)</label>
173 172
  <button type="submit">SAVE</button>
174 173
</form>
174 +
{% when Section::Diffusion %}
175 175
<form class="card wide" id="diffusion" method="post" action="{{ repo.url }}/settings/diffusion">
176 176
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
177 177
  <h2>Diffusion</h2>
…
13 unchanged lines
191 191
  <p class="small mute">A subrepo's <code>.oxymodules</code> entry overrides this with <code>follow = green</code>, <code>any</code> or <code>off</code>; <code>land = auto</code> or <code>propose</code> overrides the landing, <code>target</code> names the bookmark to move (else the one this repository's proposals land on) and <code>watch</code> lists paths whose upstream changes are flagged.</p>
192 192
  <button type="submit">SAVE</button>
193 193
</form>
194 +
{% when Section::Backup %}
194 195
<form class="card wide" id="backup" method="post" action="{{ repo.url }}/settings/backup">
195 196
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
196 197
  <h2>Backup</h2>
…
8 unchanged lines
205 206
  <p class="small mute">A repository with its own schedule makes its owner's backup run at least that often; each run sends only what changed and keeps every repository, so any run restores everything. Left out, it is in no new run; earlier runs keep it until they are pruned.</p>
206 207
  <button type="submit">SAVE</button>
207 208
</form>
208 -
{% if role == crate::auth::Role::Admin %}
209 +
{% when Section::Coverage %}
209 210
<form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage">
210 211
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
211 212
  <h2>Coverage</h2>
…
4 unchanged lines
216 217
  </div>
217 218
  <button type="submit">SAVE</button>
218 219
</form>
220 +
{% when Section::Runners %}
219 221
<section class="panel" id="runners">
220 222
  <h2>Runners</h2>
221 223
  <p class="small">The OxygenFlow runners that may take this repository's jobs: the site's, {{ row.owner }}'s, and this repository's own.</p>
…
6 unchanged lines
228 230
  </table></div>{% endif %}
229 231
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
230 232
</section>
233 +
{% when Section::Webhooks %}
231 234
{% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %}
235 +
{% when Section::Variables %}
232 236
{% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %}
237 +
{% when Section::Secrets %}
233 238
{% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %}
239 +
{% when Section::Assistants %}
234 240
{% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %}
235 -
{% if role == crate::auth::Role::Admin %}{% if let Some(st) = storage %}<section class="panel" id="storage">
241 +
{% when Section::Storage %}
242 +
{% if let Some(st) = storage %}<section class="panel" id="storage">
236 243
  <h2>Storage</h2>
237 244
  <p>This repository's data lives in <code>{{ st.url }}</code>, your bucket. This server keeps a cache and the credentials, sealed.</p>
238 245
  <dl class="facts">
…
22 unchanged lines
261 268
      <button type="submit">SAVE</button>
262 269
    </form>
263 270
  </details>
264 -
</section>{% endif %}{% endif %}
271 +
</section>{% endif %}
272 +
{% when Section::Collaborators %}
265 273
<section class="panel" id="collaborators">
266 274
  <h2>Collaborators</h2>
267 275
  {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %}
…
13 unchanged lines
281 289
    <button type="submit">ADD</button>
282 290
  </form>
283 291
</section>
292 +
{% when Section::Advanced %}
284 293
<section class="panel danger-zone" id="advanced">
285 294
  <h2>Advanced</h2>
286 295
  <p class="small mute">Settings that change what this repository is. Each asks you to confirm; none can be undone from here except archiving.</p>
…
40 unchanged lines
327 336
    </details>
328 337
  </div>
329 338
</section>
330 -
{% endif %}
339 +
{% endmatch %}
331 340
</div>
332 341
</div>
333 342
{% endblock %}
modified templates/repo_tabs.html +1 −1
1 1
{% macro tabs(repo, tab) %}
2 2
<nav class="tabs" aria-label="Repository">
3 3
  <div class="tabs-in">
4 -
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings#storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span>
4 +
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings/storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span>
5 5
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a>
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
…
86 unchanged lines
modified templates/style.css +17 −0
…
436 unchanged lines
437 437
.sections > form.card, .sections > form.card.wide, .sections > .panel, .sections > details { max-width: none; width: 100%; box-sizing: border-box; scroll-margin-top: 14px; }
438 438
.sections .panel form.card { max-width: 720px; }
439 439
.sections > details > form.card { max-width: none; }
440 +
/* A repository's settings (#278): grouped pages in a sidebar, one page beside it. */
441 +
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; }
442 +
.settings-nav { position: sticky; top: 14px; display: flex; flex-direction: column; gap: 14px; }
443 +
.settings-group h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); margin: 0 0 4px; padding: 0 12px; }
444 +
.settings-group ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
445 +
.settings-group a { display: block; padding: 5px 12px; border-left: 2px solid var(--line); color: var(--mute); font-size: 14px; }
446 +
.settings-group a:hover { border-left-color: var(--dim); color: var(--ink); text-decoration: none; }
447 +
.settings-group a.on { border-left-color: var(--acc); color: var(--ink); background: var(--soft); }
448 +
@media (max-width: 720px) {
449 +
  .settings-layout { grid-template-columns: minmax(0, 1fr); gap: 14px; }
450 +
  .settings-nav { position: static; gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
451 +
  .settings-group { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: baseline; gap: 10px; }
452 +
  .settings-group h2 { padding: 0; margin: 0; }
453 +
  .settings-group ul { flex-direction: row; flex-wrap: wrap; gap: 4px; min-width: 0; }
454 +
  .settings-group a { border-left: 0; border: 1px solid var(--line); padding: 3px 8px; font-size: 13px; }
455 +
  .settings-group a.on { border-color: var(--acc); }
456 +
}
440 457
@media (max-width: 760px) {
441 458
  .sectioned { grid-template-columns: 1fr; gap: 12px; }
442 459
  .section-nav { position: static; flex-direction: row; flex-wrap: wrap; }
…
923 unchanged lines