git-ents.gitmain
⌘K
foforge
issues.rs653 lines · 25.0 KB · rusthistorycomment on this file
1//! `GET /issues`, `GET /issues/{id}`, `POST /issues`,
2//! `POST /issues/{id}`, `POST /issues/{id}/comment`: the issue surface
3//! (`model.issue`), a top-level tab of its own (`crate::pages::Tab::Issues`;
4//! see [`super`]'s own doc) rather than an entry in the `meta` tab's
5//! registry -- issues are a working surface like comments, not repository
6//! metadata.
7//!
8//! Every read is `ents_forge::issue::{list,show}` and every mutation is
9//! `ents_forge::issue::{new,edit}` or `ents_forge::comment::add` -- the web
10//! is another caller of the same library funcs (`lens.parity`), never a
11//! second issue or thread implementation. An issue's discussion is its
12//! thread: the comments naming `issues/<id>` as their context
13//! (`model.comment-context`), aggregated by `ents_forge::comment::thread`
14//! and rendered through `crate::pages::comments::thread_section`, never a
15//! list the issue stores.
16
17use std::sync::Arc;
18
19use axum::Form;
20use axum::extract::{Path, State};
21use axum::response::{IntoResponse, Redirect};
22use ents_forge::issue::{self, EditIssue, IssueAction, NewIssue};
23use ents_model::MemberId;
24use gix_object::{Find, Write};
25use maud::{Markup, html};
26use serde::Deserialize;
27
28use crate::error::Result;
29use crate::session::Session;
30use crate::state::AppState;
31
32/// `GET /issues`: the Issues split (`crate::pages::layout_split`) --
33/// every issue recorded in this repository (`ents_forge::issue::list_all`)
34/// as the sidebar, its state/assignees/labels on each row's own locator
35/// line, beside the new-issue composer in the pane.
36///
37/// # Errors
38///
39/// Propagates a ref-store or object read failure.
40// @relation(model.issue, scope=function)
41pub async fn list<O>(
42 State(state): State<Arc<AppState<O>>>,
43 axum::Extension(session): axum::Extension<Session>,
44) -> Result<Markup>
45where
46 O: Find + Write + Send + 'static,
47{
48 let (rows, unreadable) = issue::list_all(state.refs.as_ref(), &*state.objects())?;
49 let failures: Vec<(String, String)> = unreadable
50 .into_iter()
51 .map(|entry| (entry.refname, entry.error))
52 .collect();
53 let labels = known_labels(&rows);
54 Ok(super::layout_split(
55 &super::RepoHeader::from_state(&state),
56 &super::identity_label(&state),
57 super::Tab::Issues,
58 "Issues",
59 false,
60 issues_sidebar(&rows, None),
61 html! {
62 div.readable {
63 (crate::render::unreadable_disclosure(&failures))
64 @if rows.is_empty() {
65 (super::blankslate(
66 "No issues yet",
67 html! { "Open one with the form below." },
68 ))
69 }
70 div.card {
71 div.card-header { "Open an Issue" }
72 (new_form(&session, &labels))
73 }
74 (super::members_datalist(&state))
75 }
76 },
77 ))
78}
79
80/// The Issues split's `.tree` sidebar: a `.tree-head` naming the family and
81/// carrying the "+ New" link into the composer, then every issue as a
82/// two-line `.side-row` -- its title (`.side-title`), then a `.side-meta`
83/// locator of a state-colored `.dot`, its state, assignees, and labels --
84/// linking to its own page, `.active` naming the viewed issue's id.
85fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup {
86 html! {
87 (super::tree_head("Issues", "/issues", active.is_some()))
88 @if rows.is_empty() {
89 span.tree-note { "No issues yet." }
90 }
91 @for (id, issue) in rows {
92 a.side-row.active[active == Some(id.as_str())] href={ "/issues/" (id) } {
93 span.side-title { (issue.title) }
94 span.side-meta {
95 (state_dot(&issue.state))
96 span.locator {
97 (issue.state)
98 " \u{b7} "
99 @if let Some(first) = issue.assignees.first() {
100 "@" (first.as_str())
101 @if issue.assignees.len() > 1 {
102 " +" (issue.assignees.len() - 1)
103 }
104 } @else {
105 "unassigned"
106 }
107 " \u{b7} "
108 @if issue.labels.is_empty() { "no labels" } @else { (issue.labels.join(", ")) }
109 }
110 }
111 }
112 }
113 }
114}
115
116/// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form
117/// for its state/assignees/labels, and its discussion thread -- the
118/// comments naming `issues/<id>` as their context
119/// (`ents_forge::comment::thread`, `model.comment-context`), rendered like
120/// every other conversation in this crate. The metadata `dl.entity-view`
121/// stays hand-rolled rather than [`crate::render::view`]'s generic dump:
122/// every row is a domain widget (state chip, assignee avatars, label
123/// chips, an "unassigned"/"none" placeholder), not a field's plain text.
124///
125/// # Errors
126///
127/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
128/// `id` has no issue ref at all; an issue ref whose stored tree this
129/// build cannot read back degrades to [`crate::render::unreadable`]'s
130/// marker card instead of erroring. Otherwise propagates a ref-store or
131/// object read failure.
132// @relation(model.issue, model.comment-context, scope=function)
133pub async fn show<O>(
134 State(state): State<Arc<AppState<O>>>,
135 axum::Extension(session): axum::Extension<Session>,
136 Path(id): Path<String>,
137) -> Result<Markup>
138where
139 O: Find + Write + Send + 'static,
140{
141 let issue = match issue::show(state.refs.as_ref(), &*state.objects(), &id) {
142 Ok(issue) => issue,
143 // No ref at all stays a real not-found; any other failure (a tree
144 // this build's shape cannot read back) is an existing entity this
145 // page degrades to the plain unreadable card for.
146 Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
147 Err(source) => {
148 return Ok(super::layout(
149 &super::RepoHeader::from_state(&state),
150 &super::identity_label(&state),
151 super::Tab::Issues,
152 &format!("Issue {}", ents_forge::abbreviate_id(&id)),
153 html! {
154 (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
155 div.readable { (crate::render::unreadable(&source.to_string())) }
156 },
157 ));
158 }
159 };
160 let context = format!("issues/{id}");
161 let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?;
162 let body =
163 crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } });
164 let return_to = format!("/issues/{id}");
165 // Best-effort: the sidebar listing every issue beside this one is
166 // navigation chrome, never a reason to fail the issue's own page.
167 let (rows, _unreadable) =
168 issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default();
169 let labels = known_labels(&rows);
170 Ok(super::layout_split(
171 &super::RepoHeader::from_state(&state),
172 &super::identity_label(&state),
173 super::Tab::Issues,
174 &issue.title,
175 false,
176 issues_sidebar(&rows, Some(&id)),
177 html! {
178 (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
179 div.readable {
180 div.card {
181 h1.commit-subject { (issue.title) }
182 dl.entity-view {
183 dt { "state" }
184 dd { (state_chip(&issue.state)) }
185 dt { "assignees" }
186 dd {
187 @if issue.assignees.is_empty() {
188 span { "unassigned" }
189 } @else {
190 @for assignee in &issue.assignees {
191 (super::avatar(assignee.as_str())) " @" (assignee.as_str()) " "
192 }
193 }
194 }
195 dt { "labels" }
196 dd {
197 @if issue.labels.is_empty() {
198 span { "none" }
199 } @else {
200 @for label in &issue.labels {
201 span.label-chip { (label) } " "
202 }
203 }
204 }
205 }
206 div.doc-body { (body) }
207 }
208 details.disclosure {
209 summary { "Edit state, assignees, labels" }
210 (edit_form(&session, &issue, &labels))
211 (super::members_datalist(&state))
212 }
213 h2 { "Discussion" }
214 @if thread.is_empty() {
215 (super::blankslate(
216 "No comments yet",
217 html! { "Start the discussion below." },
218 ))
219 } @else {
220 (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
221 }
222 div.card {
223 div.card-header { "Add a comment" }
224 (comment_form(&session, &id))
225 }
226 }
227 },
228 ))
229}
230
231/// `POST /issues`: open an issue at a freshly generated
232/// `refs/meta/issues/<id>` (`ents_forge::issue::new`), signed
233/// (`roots.web-signing`) on behalf of the current session
234/// (`roots.web-session`). The posted fields are
235/// [`IssueAction::New`]'s own ([`crate::form::parse_action`]), so the
236/// form's shape and this handler's parse are one declaration.
237///
238/// # Errors
239///
240/// [`crate::Error::BadCsrf`] if the posted token does not match;
241/// otherwise propagates [`ents_forge::issue::new`]'s own failures.
242// @relation(model.issue, roots.web-signing, roots.web-session, scope=function)
243pub async fn create<O>(
244 State(state): State<Arc<AppState<O>>>,
245 axum::Extension(session): axum::Extension<Session>,
246 Form(pairs): Form<Vec<(String, String)>>,
247) -> Result<impl IntoResponse>
248where
249 O: Find + Write + Send + 'static,
250{
251 super::require_csrf(&session, crate::form::posted_csrf(&pairs))?;
252 dispatch(&state, &session, crate::form::parse_action("New", &pairs)?)
253}
254
255/// `POST /issues/{id}`: mutate `id`'s state, assignees, and/or labels
256/// (`ents_forge::issue::edit`) as a signed mutation on the issue's own
257/// ref. The posted fields are [`IssueAction::Edit`]'s own, the path's
258/// `id` standing in for the CLI's positional argument.
259///
260/// # Errors
261///
262/// [`crate::Error::BadCsrf`] if the posted token does not match;
263/// otherwise propagates [`ents_forge::issue::edit`]'s own failures
264/// (including [`ents_forge::Error::NotFound`] when `id` names no issue).
265// @relation(model.issue, roots.web-signing, roots.web-session, scope=function)
266pub async fn edit<O>(
267 State(state): State<Arc<AppState<O>>>,
268 axum::Extension(session): axum::Extension<Session>,
269 Path(id): Path<String>,
270 Form(mut pairs): Form<Vec<(String, String)>>,
271) -> Result<impl IntoResponse>
272where
273 O: Find + Write + Send + 'static,
274{
275 super::require_csrf(&session, crate::form::posted_csrf(&pairs))?;
276 pairs.retain(|(name, _)| name != "id");
277 pairs.push(("id".to_owned(), id));
278 dispatch(&state, &session, crate::form::parse_action("Edit", &pairs)?)
279}
280
281/// The issue dispatch table: each mutating [`IssueAction`] variant mapped
282/// to the same `ents_forge::issue` call `git ents issue`'s own command
283/// module makes, with the same edit semantics (an empty label/assignee
284/// set leaves the field unchanged) -- `lens.parity`, the web as another
285/// caller of the one business-logic path.
286// @relation(model.issue, lens.parity, scope=function)
287fn dispatch<O>(state: &AppState<O>, session: &Session, action: IssueAction) -> Result<Redirect>
288where
289 O: Find + Write + Send + 'static,
290{
291 let identity = state.identity.as_ref();
292 let identity = crate::receive_identity!(identity, crate::pages::member_author(session));
293 match action {
294 IssueAction::New {
295 title,
296 body,
297 state: issue_state,
298 label,
299 assignee,
300 key: _,
301 } => {
302 let new = NewIssue {
303 title: title.unwrap_or_default(),
304 body: body.unwrap_or_default(),
305 state: issue_state,
306 assignees: assignee.into_iter().map(MemberId::new).collect(),
307 labels: label,
308 };
309 let (id, outcome) = issue::new(
310 state.refs.as_ref(),
311 &*state.objects(),
312 state.events.as_ref(),
313 new,
314 &identity,
315 state.mode,
316 )?;
317 crate::error::outcome_to_result(outcome)?;
318 Ok(Redirect::to(&format!("/issues/{id}")))
319 }
320 IssueAction::Edit {
321 id,
322 state: issue_state,
323 label,
324 assignee,
325 key: _,
326 } => {
327 let edit = EditIssue {
328 state: issue_state,
329 labels: (!label.is_empty()).then_some(label),
330 assignees: (!assignee.is_empty())
331 .then(|| assignee.into_iter().map(MemberId::new).collect()),
332 };
333 let outcome = issue::edit(
334 state.refs.as_ref(),
335 &*state.objects(),
336 state.events.as_ref(),
337 &id,
338 edit,
339 &identity,
340 state.mode,
341 )?;
342 crate::error::outcome_to_result(outcome)?;
343 Ok(Redirect::to(&format!("/issues/{id}")))
344 }
345 _ => Err(crate::Error::InvalidArgument(
346 "not a form-backed issue action".to_owned(),
347 )),
348 }
349}
350
351/// The form fields `POST /issues/{id}/comment` accepts.
352#[derive(Debug, Deserialize)]
353pub struct CommentForm {
354 /// The comment's body text.
355 body: String,
356 /// The per-session CSRF token (`roots.web-session`).
357 csrf: String,
358}
359
360/// `POST /issues/{id}/comment`: a comment naming `issues/<id>` as its
361/// context (`model.comment-context`) -- an ordinary
362/// [`ents_forge::comment::add`], contextual and unanchored, so it joins the
363/// issue's thread the moment it lands.
364///
365/// # Errors
366///
367/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
368/// propagates [`ents_forge::comment::add`]'s own failures.
369// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
370pub async fn comment<O>(
371 State(state): State<Arc<AppState<O>>>,
372 axum::Extension(session): axum::Extension<Session>,
373 Path(id): Path<String>,
374 Form(form): Form<CommentForm>,
375) -> Result<impl IntoResponse>
376where
377 O: Find + Write + Send + 'static,
378{
379 super::require_csrf(&session, &form.csrf)?;
380 let identity = state.identity.as_ref();
381 let new = ents_forge::comment::NewComment {
382 body: form.body,
383 path: None,
384 lines: None,
385 rev: "HEAD".to_owned(),
386 worktree: false,
387 context: Some(format!("issues/{id}")),
388 parent: None,
389 };
390 let (_comment_id, outcome) = ents_forge::comment::add(
391 state.refs.as_ref(),
392 &*state.objects(),
393 state.events.as_ref(),
394 &state.path,
395 new,
396 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
397 state.mode,
398 )?;
399 crate::error::outcome_to_result(outcome)?;
400 Ok(Redirect::to(&format!("/issues/{id}")))
401}
402
403/// The open-an-issue form (`POST /issues`). State picks from
404/// [`state_picker`]'s closed three-option enumeration (the redesign's
405/// `StatePicker`/`StateChip`, open / in-progress / closed) rather than the
406/// pre-redesign free-text-plus-datalist field: `model.issue` itself still
407/// stores state as an arbitrary string (`ents_forge::Issue`'s own doc,
408/// "custom states are schema, not a platform feature"), so this form
409/// narrowing its own three quick-pick buttons never closes that schema --
410/// a state outside the trio stays reachable through `git ents issue edit`
411/// or a direct edit, same as any other schema-level custom field.
412fn new_form(session: &Session, known_labels: &[String]) -> Markup {
413 crate::form::action_form::<IssueAction>(
414 "New",
415 session,
416 &crate::form::Spec {
417 action: "/issues",
418 submit: "Open Issue",
419 cancel: Some("/issues"),
420 values: &[],
421 overrides: &[
422 (
423 "title",
424 html! { label { "Title" input type="text" name="title"; } },
425 ),
426 (
427 "state",
428 html! { div { label { "State" } (state_picker("open")) } },
429 ),
430 ("label", label_picker(known_labels, &[])),
431 (
432 "assignee",
433 html! {
434 label {
435 "Assignees"
436 input type="text" name="assignee" placeholder="alice, bob" list="members";
437 }
438 },
439 ),
440 ],
441 },
442 )
443}
444
445/// The edit-issue form (`POST /issues/{id}`), its fields derived from
446/// [`IssueAction::Edit`]'s own shape (the positional `id` is the route's
447/// path segment, so no control renders for it) and pre-filled from the
448/// current issue. Its `state` field carries the same [`state_picker`] as
449/// [`new_form`]'s, for the same reason (see [`new_form`]'s own doc).
450fn edit_form(session: &Session, issue: &ents_forge::Issue, known_labels: &[String]) -> Markup {
451 crate::form::action_form::<IssueAction>(
452 "Edit",
453 session,
454 &crate::form::Spec {
455 action: "",
456 submit: "Save",
457 cancel: None,
458 values: &[],
459 overrides: &[
460 (
461 "state",
462 html! { div { label { "State" } (state_picker(&issue.state)) } },
463 ),
464 ("label", label_picker(known_labels, &issue.labels)),
465 (
466 "assignee",
467 html! {
468 label {
469 "Assignees"
470 input type="text" name="assignee" value=(join_members(&issue.assignees)) list="members";
471 }
472 },
473 ),
474 ],
475 },
476 )
477}
478
479/// The comment-on-this-issue form (`POST /issues/{id}/comment`).
480fn comment_form(session: &Session, id: &str) -> Markup {
481 html! {
482 form method="post" action=(format!("/issues/{id}/comment")) {
483 (super::csrf_input(session))
484 label { "Body" textarea name="body" {} }
485 button type="submit" { "Comment" }
486 }
487 }
488}
489
490/// The three conventional issue states the redesign's `StatePicker` closes
491/// over (`model.issue`'s own field stays an open string; see [`new_form`]'s
492/// doc for why the form narrows to these three anyway).
493const ISSUE_STATES: [&str; 3] = ["open", "in-progress", "closed"];
494
495/// The `.chip`/`.dot` color class for a known state, or `None` for a
496/// custom one this form's [`state_picker`] does not enumerate -- shared by
497/// [`state_chip`] (the detail card's big pill) and [`state_dot`] (the
498/// sidebar row's small status dot), so a state's color is spelled in
499/// exactly one place.
500fn state_class(state: &str) -> Option<&'static str> {
501 match state {
502 "open" => Some("state-open"),
503 "in-progress" => Some("state-in-progress"),
504 "closed" => Some("state-closed"),
505 _ => None,
506 }
507}
508
509/// The issue detail card's state `dd`: a `.chip.chip-pill` carrying a
510/// leading `.dot` and the state's own color (see [`state_class`]), plain
511/// neutral for a custom state outside the three [`ISSUE_STATES`].
512fn state_chip(state: &str) -> Markup {
513 let class = match state_class(state) {
514 Some(extra) => format!("chip chip-pill {extra}"),
515 None => "chip chip-pill".to_owned(),
516 };
517 html! {
518 span class=(class) {
519 span.dot {}
520 (state)
521 }
522 }
523}
524
525/// The sidebar row's status `.dot` (see [`issues_sidebar`]): green open,
526/// amber in-progress, grey closed or custom (see [`state_class`]).
527fn state_dot(state: &str) -> Markup {
528 let class = match state_class(state) {
529 Some(extra) => format!("dot {extra}"),
530 None => "dot".to_owned(),
531 };
532 html! { span class=(class) {} }
533}
534
535/// One [`state_picker`] option's class: `.opt`, plus `.active` and the
536/// state's own color class (see [`state_class`]) when it is `current`'s
537/// own value -- an inactive option stays the picker's plain neutral look
538/// (mirrors the design handoff's own `statePicker`, which colors only the
539/// selected option).
540fn picker_opt_class(current: &str, state: &str) -> String {
541 if current == state {
542 match state_class(state) {
543 Some(extra) => format!("opt active {extra}"),
544 None => "opt active".to_owned(),
545 }
546 } else {
547 "opt".to_owned()
548 }
549}
550
551/// The state `.picker` both [`new_form`] and [`edit_form`] render: three
552/// `name="state"` radios, one per [`ISSUE_STATES`] entry, styled as
553/// `.picker .opt` pills (a `hidden` native radio inside a `<label>` still
554/// toggles on click -- label-click activation reaches a `hidden` control
555/// same as any other -- so the pill shows no native radio glyph without
556/// needing any stylesheet change). Exactly one radio is always checked, so
557/// the field is never posted empty: `current` itself when it names one of
558/// the three, or a fourth unlabeled hidden radio carrying `current`
559/// verbatim when it does not (a custom state this form's picker does not
560/// enumerate stays intact until the reader deliberately picks a different
561/// one).
562fn state_picker(current: &str) -> Markup {
563 html! {
564 div.picker {
565 @for state in ISSUE_STATES {
566 label class=(picker_opt_class(current, state)) {
567 input type="radio" name="state" value=(state) checked[state == current] hidden;
568 span.dot {}
569 (state)
570 }
571 }
572 @if !ISSUE_STATES.contains(&current) {
573 input type="radio" name="state" value=(current) checked hidden;
574 }
575 }
576 }
577}
578
579/// Every label already used across every issue in this repository, deduped
580/// and sorted -- [`label_picker`]'s "pick existing" set, derived from the
581/// same `issue::list_all` read [`issues_sidebar`] renders from rather than
582/// a second query of its own.
583fn known_labels(rows: &[(String, ents_forge::Issue)]) -> Vec<String> {
584 let mut labels: Vec<String> = Vec::new();
585 for (_, issue) in rows {
586 for label in &issue.labels {
587 if !labels.contains(label) {
588 labels.push(label.clone());
589 }
590 }
591 }
592 labels.sort();
593 labels
594}
595
596/// The labels field both [`new_form`] and [`edit_form`] render: `known`'s
597/// labels previewed as `.label-chip` (`.on` for one already in `current`),
598/// then the actual control -- a single free-text `input[name=label]`
599/// (spelled as [`IssueAction`]'s own `label` field, which
600/// [`crate::form::parse_action`] splits on commas/whitespace) pre-filled
601/// from `current` and completed by a [`label_datalist`] of `known`'s own
602/// names. Unlike [`state_picker`]'s single-choice radios, real
603/// independently-toggleable checkboxes are not this control's shape here:
604/// a checkbox group needs client-side script to merge checkbox state into
605/// the text value a no-JS post also carries (excluded -- this crate works
606/// with no JS at all). The datalist gives "pick existing" a real, working
607/// affordance; typing any other word is "type-and-create".
608fn label_picker(known: &[String], current: &[String]) -> Markup {
609 html! {
610 div {
611 label { "Labels" }
612 @if !known.is_empty() {
613 div.picker {
614 @for label in known {
615 @let on = current.iter().any(|applied| applied == label);
616 span class={ "label-chip" (if on { " on" } else { "" }) } { (label) }
617 }
618 }
619 }
620 input
621 type="text"
622 name="label"
623 value=(current.join(", "))
624 placeholder="bug, gate"
625 list="issue-labels";
626 (label_datalist(known))
627 }
628 }
629}
630
631/// The `datalist` of every already-used label [`label_picker`]'s free-text
632/// input completes from. Rendered once per form; the two forms never
633/// share a page, so the id never collides (same reasoning the
634/// pre-redesign `state_datalist` carried).
635fn label_datalist(known: &[String]) -> Markup {
636 html! {
637 datalist id="issue-labels" {
638 @for label in known { option value=(label) {} }
639 }
640 }
641}
642
643/// Render a member set for display, comma-joined (`join_members(&[])` is the
644/// empty string, so an unassigned issue shows a blank cell rather than a
645/// stray separator).
646fn join_members(members: &[MemberId]) -> String {
647 members
648 .iter()
649 .map(MemberId::as_str)
650 .collect::<Vec<_>>()
651 .join(", ")
652}
653