roots: add the issues surface with threads as context queries
commit 2da0244
roots: add the issues surface with threads as context queries
Issues join the meta tab’s registry (META_SECTIONS) like every other
entity family. GET /issues lists each issue’s title, state, assignees,
and labels; GET /issues/{id} shows one issue, an edit form, and its
discussion — the comments naming issues/<id> as their context
(comment::thread), rendered through a shared thread_section reused by
reviews next. POST /issues opens one (issue::new), POST /issues/{id}
edits state/assignees/labels (issue::edit), and POST /issues/{id}/comment
adds a context comment (comment::add), each a CSRF-checked signed POST
through the injected identity.
Implements model.issue and model.comment-context in the web layer;
roots.web-signing, roots.web-session on every mutation route.
crates/cli/ents-web/tests/router.rs
@@ -1781,6 +1781,169 @@
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
+/// `POST /issues` through the real signed-write path
+/// (`ents_forge::issue::new`), returning the new issue's id from the
+/// redirect's `Location` (`/issues/<id>`). Asserts the write succeeded.
+async fn seed_issue(
+ router: &axum::Router,
+ state: &AppState<ObjectStore>,
+ title: &str,
+ issue_state: &str,
+ assignees: &str,
+ labels: &str,
+) -> String {
+ let (cookie, csrf) = session_cookie_and_csrf(router, state, "/issues").await;
+ let form = format!(
+ "title={}&state={issue_state}&assignees={assignees}&labels={labels}&body=the+full+body&csrf={csrf}",
+ title.replace(' ', "+")
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/issues")
+ .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(),
+ "issue create did not succeed: {:?}",
+ response.status()
+ );
+ response
+ .headers()
+ .get(header::LOCATION)
+ .expect("a successful issue create redirects to the new issue")
+ .to_str()
+ .expect("ascii")
+ .strip_prefix("/issues/")
+ .expect("redirect targets /issues/<id>")
+ .to_owned()
+}
+
+/// `model.issue`, `model.comment-context`: the issues index lists a seeded
+/// issue with its state, assignees, and labels; the detail page shows the
+/// issue and its discussion thread; a comment naming `issues/<id>` as its
+/// context joins that thread; and an edit changes the issue's state -- every
+/// mutation a CSRF-checked signed POST calling the same `ents_forge` funcs
+/// the CLI and lens do.
+#[tokio::test]
+// @relation(model.issue, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn issues_index_and_detail_render_a_seeded_issue_and_its_context_comment() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_issue(
+ &router,
+ &state,
+ "gate rejects a valid signature",
+ "triaged",
+ "jdc",
+ "bug",
+ )
+ .await;
+
+ // The index lists the issue with its state, assignees, and labels, and
+ // links into its detail page.
+ let index = get_body(&router, "/issues").await;
+ assert!(index.contains("gate rejects a valid signature"));
+ assert!(index.contains("triaged"));
+ assert!(index.contains("jdc"));
+ assert!(index.contains("bug"));
+ assert!(index.contains(&format!("/issues/{id}")));
+
+ // The detail page shows the issue and an (initially empty) discussion.
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(detail.contains("gate rejects a valid signature"));
+ assert!(detail.contains("the full body"));
+ assert!(detail.contains("discussion"));
+
+ // A comment naming the issue as its context joins the thread.
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
+ let comment = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/issues/{id}/comment"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(format!("body=cannot+reproduce+yet&csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(comment.status().is_redirection(), "{:?}", comment.status());
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(
+ detail.contains("cannot reproduce yet"),
+ "the context comment renders in the issue's thread: {detail}"
+ );
+
+ // Editing the issue's state lands and reads back.
+ let edited = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/issues/{id}"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("state=closed&csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(edited.status().is_redirection());
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(detail.contains("closed"), "the edited state reads back");
+}
+
+/// `roots.web-session`: opening an issue is a state-changing route, so a
+/// `POST /issues` with no CSRF field at all is rejected, and one with the
+/// wrong token is a bad request -- the same gate every mutation in this
+/// crate runs behind.
+#[tokio::test]
+// @relation(model.issue, roots.web-session, scope=function, role=Verifies)
+async fn issue_create_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ // No CSRF field at all.
+ let no_csrf = router
+ .clone()
+ .oneshot(
+ Request::post("/issues")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from("title=sneaky&state=open"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
+ "a POST with no csrf field must not open an issue"
+ );
+
+ // A session's cookie, but the wrong token.
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
+ let wrong = router
+ .oneshot(
+ Request::post("/issues")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from("title=sneaky&state=open&csrf=not-the-token"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
+}
+
/// `GET /search?q=` finds a known fixture file path, linking into its
/// `/files/...` blob view.
#[tokio::test]
crates/cli/ents-web/src/pages/comments.rs
@@ -643,6 +643,66 @@
}
}
+/// One comment in an entity's discussion thread -- an issue's
+/// (`crate::pages::issues::show`) or a review's
+/// (`crate::pages::commits::show`) -- rendered from an aggregation query
+/// (`comment::thread`, `model.comment-context`), never a list any entity
+/// stores. Author and time come from the comment ref's own tip commit
+/// (`super::commit_authorship`, `model.comment`: no stored author field),
+/// its state (`model.comment-state`) shows as a badge, its body renders as
+/// AsciiDoc, and it carries the same [`action_forms`] every comment does,
+/// with `return_to` pointing back at the entity page rendering it so a
+/// reply or resolve returns there. Best effort: a comment whose tip commit
+/// cannot be read still renders, only without an author line.
+pub(crate) fn thread_comment_card<O: Find + Write>(
+ state: &AppState<O>,
+ session: &Session,
+ id: &str,
+ comment: &ents_forge::comment::Comment,
+ return_to: &str,
+) -> Markup {
+ let authorship = ents_model::namespace::comment_ref(id)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
+ let body =
+ crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
+ html! {
+ div.card id={ "thread-" (id) } {
+ div.comment-meta {
+ @if let Some((author, seconds)) = &authorship {
+ span.author { (author) }
+ span { (super::ago(*seconds)) }
+ }
+ @if comment.parent.is_some() {
+ span { "reply" }
+ }
+ span.comment-state { (comment.state) }
+ }
+ div.doc-body { (body) }
+ (action_forms(session, id, comment.state == "resolved", return_to))
+ }
+ }
+}
+
+/// An entity's whole discussion thread as a stack of [`thread_comment_card`]s
+/// (`model.comment-context`, `model.comment-thread`) -- what
+/// `crate::pages::issues::show` and `crate::pages::commits::show` render a
+/// `comment::thread` result through. Renders nothing when the thread is
+/// empty.
+pub(crate) fn thread_section<O: Find + Write>(
+ state: &AppState<O>,
+ session: &Session,
+ thread: &[(String, ents_forge::comment::Comment)],
+ return_to: &str,
+) -> Markup {
+ html! {
+ @for (id, comment) in thread {
+ (thread_comment_card(state, session, id, comment, return_to))
+ }
+ }
+}
+
/// The comment cards under a blob view (a rendered document, a binary
/// placeholder, or -- for a raw-source view -- the ones with no current
/// line range to interleave at; see `crate::pages::files::source_view`),
crates/cli/ents-web/src/pages/mod.rs
@@ -6,14 +6,14 @@
//! and [`inbox`] are the generic pages: they read a kernel entity and
//! render it through [`crate::render`]'s reflection-driven mechanism,
//! never matching on which entity type they were handed.
-//! [`toolchains`] and [`comments`] are legitimate custom pages
+//! [`toolchains`], [`comments`], and [`issues`] are legitimate custom pages
//! (`ents-kiln`'s recipe provenance and `ents-forge`'s anchor projection
-//! both need domain-specific rendering no generic reflection walk should
-//! grow special cases for). [`members`], [`effects`], [`toolchains`],
-//! [`redactions`], and [`inbox`] additionally share one `meta` tab and
-//! [`META_SECTIONS`] rail rather than each carrying its own top-level tab
-//! (see [`Tab`]'s own doc); [`meta`] is that group's `GET /meta` landing
-//! page. [`commits`] is a view of the code, not a tab of its own -- both
+//! and issue threads all need domain-specific rendering no generic
+//! reflection walk should grow special cases for). [`issues`], [`members`],
+//! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally
+//! share one `meta` tab and [`META_SECTIONS`] rail rather than each carrying
+//! its own top-level tab (see [`Tab`]'s own doc); [`meta`] is that group's
+//! `GET /meta` landing page. [`commits`] is a view of the code, not a tab of its own -- both
//! its routes render with [`Tab::Files`] active, reached from
//! [`files`]'s own "history" link. [`search`] renders with no tab active
//! at all, like [`account`]; it is reached from [`layout`]'s own nav
@@ -26,6 +26,7 @@
pub mod effects;
pub mod files;
pub mod inbox;
+pub mod issues;
pub mod members;
pub mod meta;
pub mod redactions;
@@ -104,9 +105,10 @@
/// [`layout`]'s nav bar, so a handler can name which tab it renders behind
/// without `layout` re-deriving it from the request path (mirrors
/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `Tab` enum,
-/// trimmed to four primary tabs). `Meta` covers five page families
-/// ([`super::members`], [`super::effects`], [`super::toolchains`],
-/// [`super::redactions`], [`super::inbox`]) behind one tab and the
+/// trimmed to four primary tabs). `Meta` covers six page families
+/// ([`super::issues`], [`super::members`], [`super::effects`],
+/// [`super::toolchains`], [`super::redactions`], [`super::inbox`]) behind
+/// one tab and the
/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than a tab each --
/// nine equal tabs did not scale as page families grew. `Account` matches
/// none of [`layout`]'s tab-strip arms, so it highlights nothing there;
@@ -145,6 +147,11 @@
/// The `meta` tab's registry (see [`MetaSection`]'s own doc).
pub(crate) const META_SECTIONS: &[MetaSection] = &[
+ MetaSection {
+ name: "issues",
+ href: "/issues",
+ blurb: "Filed issues and their discussion threads.",
+ },
MetaSection {
name: "members",
href: "/members",
crates/cli/ents-web/src/pages/issues.rs
@@ -1,0 +1,374 @@
+//! `GET /issues`, `GET /issues/{id}`, `POST /issues`,
+//! `POST /issues/{id}`, `POST /issues/{id}/comment`: the issue surface
+//! (`model.issue`), an entity family under the `meta` tab's registry
+//! (`crate::pages::META_SECTIONS`) exactly as members, effects, and the
+//! rest are.
+//!
+//! Every read is `ents_forge::issue::{list,show}` and every mutation is
+//! `ents_forge::issue::{new,edit}` or `ents_forge::comment::add` -- the web
+//! is another caller of the same library funcs (`lens.parity`), never a
+//! second issue or thread implementation. An issue's discussion is its
+//! thread: the comments naming `issues/<id>` as their context
+//! (`model.comment-context`), aggregated by `ents_forge::comment::thread`
+//! and rendered through `crate::pages::comments::thread_section`, never a
+//! list the issue stores.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_forge::issue::{self, EditIssue, NewIssue};
+use ents_model::MemberId;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /issues`: every issue recorded in this repository
+/// (`ents_forge::issue::list`) -- title, state, assignees, and labels --
+/// plus the new-issue form.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+// @relation(model.issue, scope=function)
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let rows = issue::list(state.refs.as_ref(), &*state.objects())?;
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/issues",
+ "issues",
+ html! {
+ @if rows.is_empty() {
+ p { "No issues yet." }
+ } @else {
+ table.entity-list {
+ thead {
+ tr { th { "issue" } th { "state" } th { "assignees" } th { "labels" } }
+ }
+ tbody {
+ @for (id, issue) in &rows {
+ tr {
+ td { a href=(format!("/issues/{id}")) { (issue.title) } }
+ td { span.comment-state { (issue.state) } }
+ td { (join_members(&issue.assignees)) }
+ td { (issue.labels.join(", ")) }
+ }
+ }
+ }
+ }
+ }
+ h2 { "open an issue" }
+ (new_form(&session))
+ },
+ ))
+}
+
+/// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form
+/// for its state/assignees/labels, and its discussion thread -- the
+/// comments naming `issues/<id>` as their context
+/// (`ents_forge::comment::thread`, `model.comment-context`), rendered like
+/// every other conversation in this crate.
+///
+/// # Errors
+///
+/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
+/// `id` has no issue ref; otherwise propagates a ref-store or object read
+/// failure.
+// @relation(model.issue, model.comment-context, scope=function)
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let issue = issue::show(state.refs.as_ref(), &*state.objects(), &id)?;
+ let context = format!("issues/{id}");
+ let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?;
+ let body =
+ crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } });
+ let return_to = format!("/issues/{id}");
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/issues",
+ &issue.title,
+ html! {
+ div.card {
+ dl {
+ dt { "state" } dd { span.comment-state { (issue.state) } }
+ dt { "assignees" } dd { (join_members(&issue.assignees)) }
+ dt { "labels" } dd { (issue.labels.join(", ")) }
+ }
+ div.doc-body { (body) }
+ }
+ details {
+ summary { "edit" }
+ (edit_form(&session, &issue))
+ }
+ h2 { "discussion" }
+ (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
+ h2 { "add a comment" }
+ (comment_form(&session, &id))
+ },
+ ))
+}
+
+/// The form fields `POST /issues` accepts.
+#[derive(Debug, Deserialize)]
+pub struct NewForm {
+ /// The issue's title.
+ title: String,
+ /// The issue's body.
+ #[serde(default)]
+ body: String,
+ /// The issue's initial state; defaults to `open` (`model.issue`: the
+ /// platform has no default of its own, so the frontend chooses one).
+ #[serde(default = "default_state")]
+ state: String,
+ /// Comma- or whitespace-separated assignee usernames.
+ #[serde(default)]
+ assignees: String,
+ /// Comma- or whitespace-separated labels.
+ #[serde(default)]
+ labels: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+fn default_state() -> String {
+ "open".to_owned()
+}
+
+/// `POST /issues`: open an issue at a freshly generated
+/// `refs/meta/issues/<id>` (`ents_forge::issue::new`), signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::issue::new`]'s own failures.
+// @relation(model.issue, 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<NewForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = NewIssue {
+ title: form.title,
+ body: form.body,
+ state: form.state,
+ assignees: parse_members(&form.assignees),
+ labels: parse_labels(&form.labels),
+ };
+ let (id, outcome) = issue::new(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The form fields `POST /issues/{id}` accepts. Each field replaces its
+/// counterpart on the issue; an empty `assignees`/`labels` leaves that set
+/// unchanged (matching `git ents issue edit`'s own semantics), while `state`
+/// is always applied.
+#[derive(Debug, Deserialize)]
+pub struct EditForm {
+ /// Replace the issue's state.
+ state: String,
+ /// Comma- or whitespace-separated assignees; empty leaves them.
+ #[serde(default)]
+ assignees: String,
+ /// Comma- or whitespace-separated labels; empty leaves them.
+ #[serde(default)]
+ labels: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /issues/{id}`: mutate `id`'s state, assignees, and/or labels
+/// (`ents_forge::issue::edit`) as a signed mutation on the issue's own ref.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::issue::edit`]'s own failures (including
+/// [`ents_forge::Error::NotFound`] when `id` names no issue).
+// @relation(model.issue, roots.web-signing, roots.web-session, scope=function)
+pub async fn edit<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<EditForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let assignees = parse_members(&form.assignees);
+ let labels = parse_labels(&form.labels);
+ let edit = EditIssue {
+ state: Some(form.state),
+ assignees: (!assignees.is_empty()).then_some(assignees),
+ labels: (!labels.is_empty()).then_some(labels),
+ };
+ let outcome = issue::edit(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ edit,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The form fields `POST /issues/{id}/comment` accepts.
+#[derive(Debug, Deserialize)]
+pub struct CommentForm {
+ /// The comment's body text.
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /issues/{id}/comment`: a comment naming `issues/<id>` as its
+/// context (`model.comment-context`) -- an ordinary
+/// [`ents_forge::comment::add`], contextual and unanchored, so it joins the
+/// issue's thread the moment it lands.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::add`]'s own failures.
+// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
+pub async fn comment<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<CommentForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: None,
+ lines: None,
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: Some(format!("issues/{id}")),
+ parent: None,
+ };
+ let (_comment_id, outcome) = ents_forge::comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The open-an-issue form (`POST /issues`).
+fn new_form(session: &Session) -> Markup {
+ html! {
+ form method="post" action="/issues" {
+ (super::csrf_input(session))
+ label { "title" input type="text" name="title"; }
+ label { "state" input type="text" name="state" value="open"; }
+ label { "assignees" input type="text" name="assignees" placeholder="alice, bob"; }
+ label { "labels" input type="text" name="labels" placeholder="bug, gate"; }
+ label { "body" textarea name="body" {} }
+ button type="submit" { "open issue" }
+ }
+ }
+}
+
+/// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from
+/// the current issue.
+fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup {
+ html! {
+ form method="post" action="" {
+ (super::csrf_input(session))
+ label { "state" input type="text" name="state" value=(issue.state); }
+ label {
+ "assignees"
+ input type="text" name="assignees" value=(join_members(&issue.assignees));
+ }
+ label { "labels" input type="text" name="labels" value=(issue.labels.join(", ")); }
+ button type="submit" { "save" }
+ }
+ }
+}
+
+/// The comment-on-this-issue form (`POST /issues/{id}/comment`).
+fn comment_form(session: &Session, id: &str) -> Markup {
+ html! {
+ form method="post" action=(format!("/issues/{id}/comment")) {
+ (super::csrf_input(session))
+ label { "body" textarea name="body" {} }
+ button type="submit" { "comment" }
+ }
+ }
+}
+
+/// Render a member set for display, comma-joined (`join_members(&[])` is the
+/// empty string, so an unassigned issue shows a blank cell rather than a
+/// stray separator).
+fn join_members(members: &[MemberId]) -> String {
+ members
+ .iter()
+ .map(MemberId::as_str)
+ .collect::<Vec<_>>()
+ .join(", ")
+}
+
+/// Parse a comma- or whitespace-separated list into members, dropping empty
+/// segments so a trailing comma or extra spacing does not enroll a blank
+/// assignee.
+fn parse_members(text: &str) -> Vec<MemberId> {
+ parse_labels(text).into_iter().map(MemberId::new).collect()
+}
+
+/// Parse a comma- or whitespace-separated list into labels, dropping empty
+/// segments.
+fn parse_labels(text: &str) -> Vec<String> {
+ text.split([',', ' ', '\t', '\n'])
+ .map(str::trim)
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_owned)
+ .collect()
+}