git-ents.gitmain
⌘K
foforge
commit a734cc6
roots: add effect and toolchain forms to the web ui

POST /effects mirrors git ents effect add, including its trigger-must-parse pre-write check; POST /toolchains records a recipe given in Recipe::parse’s text format via toolchain::register.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/router.rs @@ -46,7 +46,10 @@ "/account", get(pages::account::show::<O>).post(pages::account::update::<O>), ) - .route("/effects", get(pages::effects::list::<O>)) + .route( + "/effects", + get(pages::effects::list::<O>).post(pages::effects::create::<O>), + ) .route("/effects/{name}", get(pages::effects::show::<O>)) .route("/commits", get(pages::commits::list::<O>)) .route("/commit/{oid}", get(pages::commits::show::<O>)) @@ -61,7 +64,10 @@ .route("/redactions", get(pages::redactions::list::<O>)) .route("/redactions/{id}", get(pages::redactions::show::<O>)) .route("/search", get(pages::search::show::<O>)) - .route("/toolchains", get(pages::toolchains::list::<O>)) + .route( + "/toolchains", + get(pages::toolchains::list::<O>).post(pages::toolchains::register::<O>), + ) .route("/toolchains/{name}", get(pages::toolchains::show::<O>)) .route( "/comments",
crates/cli/ents-web/tests/router.rs @@ -2221,6 +2221,95 @@ ); } +/// `POST /effects` defines an effect as a signed mutation on +/// `refs/meta/effects/<name>` and redirects to its show page; the list +/// page then names it -- the web counterpart of `git ents effect add`. +#[tokio::test] +async fn effect_form_defines_an_effect() { + let dir = seed_repo(&[("README.md", "# hi\n")]); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state.clone()); + let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/effects").await; + + let form = format!( + "name=unit&trigger=rev(refs/heads/main)&run=cargo+nextest+run&toolchains=rust&csrf={csrf}" + ); + let response = router + .clone() + .oneshot( + Request::post("/effects") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from(form)) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!( + response.status().is_redirection(), + "effect write did not succeed: {:?}", + response.status() + ); + + let list = get_body(&router, "/effects").await; + assert!(list.contains("unit"), "the new effect lists: {list}"); + let show = get_body(&router, "/effects/unit").await; + assert!( + show.contains("rev(refs/heads/main)") && show.contains("parses"), + "the show page renders the trigger and its parse check: {show}" + ); +} + +/// `POST /toolchains` records a toolchain from a recipe given as text +/// (`ents_kiln::toolchain::register`) and redirects to its show page -- +/// the recipe-flow counterpart of `git ents toolchain import`. +#[tokio::test] +async fn toolchain_form_registers_a_recipe() { + let dir = seed_repo(&[("README.md", "# hi\n")]); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state.clone()); + let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/toolchains").await; + + let form = + format!("name=empty&recipe=embedded+4b825dc642cb6eb9a060e54bf8d69288fbee4904&csrf={csrf}"); + let response = router + .clone() + .oneshot( + Request::post("/toolchains") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from(form)) + .expect("request"), + ) + .await + .expect("in-process call"); + assert!( + response.status().is_redirection(), + "toolchain write did not succeed: {:?}", + response.status() + ); + + let list = get_body(&router, "/toolchains").await; + assert!(list.contains("empty"), "the new toolchain lists: {list}"); + let show = get_body(&router, "/toolchains/empty").await; + assert!( + show.contains("Embedded"), + "the show page renders the recorded recipe: {show}" + ); +} + /// `GET /toolchains` surfaces a toolchain written by an older schema /// (piece 1's bug: this repository's own /// `refs/meta/toolchains/{rust,sccache,zig}` still carry it) through the
crates/cli/ents-web/src/pages/effects.rs @@ -8,13 +8,17 @@ use std::sync::Arc; +use axum::Form; use axum::extract::{Path, State}; -use ents_model::Effect; +use axum::response::{IntoResponse, Redirect}; +use ents_model::{Effect, namespace}; use ents_query::Query; use gix_object::{Find, Write}; use maud::html; +use serde::Deserialize; use crate::error::{Error, Result}; +use crate::session::Session; use crate::state::AppState; /// `GET /effects`. @@ -22,7 +26,10 @@ /// # Errors /// /// Propagates a ref-store or object read failure. -pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup> +pub async fn list<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, +) -> Result<maud::Markup> where O: Find + Write + Send + 'static, { @@ -37,7 +44,7 @@ let table = if rows.is_empty() { super::blankslate( "No effects yet", - html! { "Registered effects and their trigger queries appear here." }, + html! { "Define one with the form below." }, ) } else { crate::render::list_table(&rows, "name", |id| format!("/effects/{id}")) @@ -50,10 +57,103 @@ html! { (crate::render::unreadable_disclosure(&failures)) (table) + h2 { "Define an Effect" } + (add_form(&session)) }, )) } +/// The define-effect form (`POST /effects`) -- `git ents effect add`'s +/// own arguments as form fields. +fn add_form(session: &Session) -> maud::Markup { + html! { + form method="post" action="/effects" { + (super::csrf_input(session)) + label { "name" input type="text" name="name"; } + label { + "trigger" + input type="text" name="trigger" placeholder="query.grammar trigger"; + } + label { "run" input type="text" name="run" placeholder="command to run"; } + label { + "toolchains" + input type="text" name="toolchains" placeholder="rust, node"; + } + button type="submit" { "Define Effect" } + } + } +} + +/// The form fields `POST /effects` accepts. +#[derive(Debug, Deserialize)] +pub struct AddForm { + /// Name to record the effect under (`refs/meta/effects/<name>`). + name: String, + /// The query the effect triggers on (`query.grammar`). + trigger: String, + /// The command the effect runs. + run: String, + /// Comma- or whitespace-separated toolchain names. + #[serde(default)] + toolchains: String, + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +/// `POST /effects`: define (or replace) an effect as a signed mutation on +/// `refs/meta/effects/<name>` -- the web counterpart of +/// `git ents effect add`, sharing its pre-write rule that the trigger must +/// parse (`ents_receive::reconcile`'s tolerance rule would otherwise +/// silently skip a malformed one on every future scan). +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; +/// [`Error::InvalidArgument`] on an unparsable trigger or an empty name; +/// otherwise propagates the `receive` proposal's own failures. +// @relation(roots.web-signing, roots.web-session, scope=function) +pub async fn create<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Form(form): Form<AddForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let _: Query = form.trigger.parse().map_err(|_source| { + Error::InvalidArgument(format!("unparsable trigger: {}", form.trigger)) + })?; + let name = form.name.trim(); + let ref_name = namespace::effect_ref(name) + .map_err(|_invalid| Error::InvalidArgument(format!("invalid effect name: {name}")))?; + let effect = Effect { + name: name.to_owned(), + trigger: form.trigger, + toolchains: form + .toolchains + .split([',', ' ']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect(), + run: form.run, + }; + let identity = state.identity.as_ref(); + let outcome = ents_receive::propose_entity( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + ref_name, + &effect, + &crate::receive_identity!(identity), + &format!("Define effect {name}"), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/effects/{name}"))) +} + /// `GET /effects/{name}`. /// /// # Errors
crates/cli/ents-web/src/pages/toolchains.rs @@ -3,19 +3,26 @@ //! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs //! `Downloaded`, each with its own provenance shape) that would otherwise //! push a `match Recipe::Embedded { .. } => ...` into the generic -//! reflection walk [`crate::render`] exists to keep type-agnostic. Import -//! is not wired here: it stays a `git ents toolchain import` operation, -//! since it takes a local directory path, not form data a browser can -//! supply. +//! reflection walk [`crate::render`] exists to keep type-agnostic. +//! Directory import stays a `git ents toolchain import` operation (it +//! takes a local directory path, not form data a browser can supply); +//! what `POST /toolchains` wires instead is [`toolchain::register`], +//! taking a recipe as text ([`ents_kiln::Recipe::parse`]'s own format) +//! -- an `embedded <tree-oid>` line or a `downloaded` component list is +//! exactly form data. use std::sync::Arc; +use axum::Form; use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Redirect}; use ents_kiln::toolchain; use gix_object::{Find, Write}; use maud::html; +use serde::Deserialize; use crate::error::{Error, Result}; +use crate::session::Session; use crate::state::AppState; /// `GET /toolchains`. @@ -33,7 +40,10 @@ /// # Errors /// /// Propagates a ref-store read failure. -pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup> +pub async fn list<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, +) -> Result<maud::Markup> where O: Find + Write + Send + 'static, { @@ -70,10 +80,85 @@ html! { (crate::render::unreadable_disclosure(&failures)) (listing) + h2 { "Import a Toolchain" } + (import_form(&session)) }, )) } +/// The import-toolchain form (`POST /toolchains`): a name and a recipe in +/// [`ents_kiln::Recipe::parse`]'s own text format. +fn import_form(session: &Session) -> maud::Markup { + html! { + form method="post" action="/toolchains" { + (super::csrf_input(session)) + label { "name" input type="text" name="name"; } + label { + "recipe" + textarea name="recipe" + placeholder="embedded <tree-oid>\nor:\ndownloaded\n<url> <sha256> <strip> [dest]" {} + } + button type="submit" { "Import Toolchain" } + } + } +} + +/// The form fields `POST /toolchains` accepts. +#[derive(Debug, Deserialize)] +pub struct ImportForm { + /// Name to record the toolchain under (`refs/meta/toolchains/<name>`). + name: String, + /// The recipe text ([`ents_kiln::Recipe::parse`]). + recipe: String, + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +/// `POST /toolchains`: record a toolchain from a recipe given as text +/// ([`toolchain::register`]) as a signed mutation on +/// `refs/meta/toolchains/<name>` -- the recipe-flow counterpart of +/// `git ents toolchain import`, whose directory walk cannot arrive as +/// form data (this module's own top-level doc). +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; +/// [`Error::InvalidArgument`] on a recipe that does not parse or a name +/// that cannot form a ref; otherwise propagates the `receive` proposal's +/// own failures. +// @relation(roots.web-signing, roots.web-session, scope=function) +pub async fn register<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Form(form): Form<ImportForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let recipe = ents_kiln::Recipe::parse(&form.recipe) + .map_err(|source| Error::InvalidArgument(format!("invalid recipe: {source}")))?; + let name = form.name.trim(); + let identity = state.identity.as_ref(); + let outcome = toolchain::register( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + name, + &recipe, + &crate::receive_identity!(identity), + state.mode, + ) + .map_err(|source| match source { + ents_effect::Error::InvalidToolchainName(bad) => { + Error::InvalidArgument(format!("invalid toolchain name: {bad}")) + } + other => Error::from(other), + })?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/toolchains/{name}"))) +} + /// `GET /toolchains/{name}`: the toolchain's recorded recipe and import /// log. ///