//! Page: milestones (`/{owner}/{repo}/milestones?state=open|closed`). Each row shows the title, due //! date (flagged "overdue" when open and past due in the viewer's timezone), the description //! rendered as Markdown, and a progress bar with open and closed issue counts that link to the //! filtered issue list. Triage holders get a create form, edit pages and delete forms. use forge_core::issues::IssueState; use jiff::Timestamp; use jiff::tz::TimeZone; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, not_found, see_other}; use topcoat::router::{page, path_param, query_params}; use topcoat::view::{Unescaped, View, component, view}; use crate::auth::require_user; use crate::i18n::issues::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::issue_meta::{ RepoFrame, action_error_at, can_triage, issue_subnav, refusal_message, triage_guard, }; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::render::markdown::render_markdown; use crate::state::AppState; use crate::views::{enc, shell}; path_param!(owner); path_param!(repo); path_param!(milestone_id); #[query_params(error = bad_request)] struct MilestoneQuery { state: Option, } /// The create form, and the edit form (which also sends `state`). #[derive(Deserialize)] struct MilestoneForm { #[serde(default)] csrf: String, #[serde(default)] title: String, #[serde(default)] description: String, #[serde(default)] due_on: String, #[serde(default)] state: String, } /// The body of the delete form, which only carries the CSRF token. #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } /// One milestone as the list shows it. struct MilestoneRow { title: String, description_html: String, due: Option, overdue: bool, open: i64, closed: i64, open_href: String, closed_href: String, edit_href: String, delete_action: String, } /// Today's date (`YYYY-MM-DD`) in the viewer's timezone, UTC for anonymous viewers. fn today_in(tz: &str) -> String { let now = Timestamp::now(); let zoned = now .in_tz(tz) .unwrap_or_else(|_| now.to_zoned(TimeZone::UTC)); zoned.date().to_string() } /// `/{owner}/{repo}/milestones` for a repo title (`owner/name`). fn milestones_href(title: &str) -> String { format!("/{title}/milestones") } /// Turns the form's optional date into the store's `Option`. fn due_on_field(raw: &str) -> Option<&str> { let trimmed = raw.trim(); (!trimmed.is_empty()).then_some(trimmed) } /// The Open and Closed filter links, with the current one marked. #[component] async fn state_tabs(lang: Lang, title: String, closed: bool) -> Result { let open_href = format!("{}?state=open", milestones_href(&title)); let closed_href = format!("{}?state=closed", milestones_href(&title)); let open_class = if closed { "subnav-link" } else { "subnav-link current" }; let closed_class = if closed { "subnav-link current" } else { "subnav-link" }; Ok(view! { }) } /// One row: title, due date, description, progress and the triage controls. #[component] async fn milestone_row( lang: Lang, row: MilestoneRow, csrf: String, can_triage: bool, ) -> Result { let total = row.open + row.closed; let max = total.max(1); let open_text = g::open_milestone_count(lang, row.open); let closed_text = g::closed_milestone_count(lang, row.closed); Ok(view! {
  • (row.title.as_str())

    if let Some(due) = row.due.as_deref() { (g::due_text(lang, due)) if row.overdue { " " (g::t(lang, K::Overdue)) } } else { (g::t(lang, K::NoDueDate)) }

    if !row.description_html.is_empty() {
    (Unescaped::new_unchecked(row.description_html.clone()))
    } if can_triage {
    }
  • }) } /// The create form for triage holders. #[component] async fn milestone_create_form(lang: Lang, csrf: String, action: String) -> Result { Ok(view! {

    (g::t(lang, K::NewMilestone))

    }) } /// Gets the milestone list for one state (open by default). #[page("/{owner}/{repo}/milestones")] async fn milestones_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let closed = query_params::(cx)?.state.as_deref() == Some("closed"); let state = if closed { IssueState::Closed } else { IssueState::Open }; let milestones = app_context::(cx) .store .list_milestones(&ctx.repo.id, Some(state)) .await?; let frame = RepoFrame::new(&ctx, app_context::(cx), "issues").await?; let today = today_in(ctx.viewer_tz()); let title = frame.title.clone(); let rows: Vec = milestones .into_iter() .map(|m| { let overdue = m.state == IssueState::Open && m.due_on.as_deref().is_some_and(|due| due < today.as_str()); MilestoneRow { description_html: render_markdown(&m.description, None), overdue, open_href: format!("/{title}/issues?milestone={}&state=open", enc(&m.title)), closed_href: format!("/{title}/issues?milestone={}&state=closed", enc(&m.title)), edit_href: format!("{}/{}/edit", milestones_href(&title), m.id), delete_action: format!("{}/{}/delete", milestones_href(&title), m.id), title: m.title, due: m.due_on, open: m.open_issues, closed: m.closed_issues, } }) .collect(); let empty = rows.is_empty(); let page_title = g::milestones_title(lang, &frame.title); let csrf = frame.csrf.clone(); let can_create = frame.can_triage; let create_action = milestones_href(&frame.title); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), milestones_body(lang: lang, frame: frame, rows: rows, empty: empty, closed: closed, csrf: csrf, can_create: can_create, create_action: create_action)) }) } /// The header, state tabs, milestone rows and the create form. #[component] async fn milestones_body( lang: Lang, frame: RepoFrame, rows: Vec, empty: bool, closed: bool, csrf: String, can_create: bool, create_action: String, ) -> Result { let title = frame.title.clone(); let can_triage = frame.can_triage; let row_csrf = csrf.clone(); let header = frame.head.clone(); Ok(view! { repo_head(head: header) issue_subnav(lang: lang, title: title.clone(), active: "milestones".to_owned()) if frame.archived {
    (g::t(lang, K::MilestonesArchived))
    } state_tabs(lang: lang, title: title.clone(), closed: closed)
    if empty {

    (if closed { g::t(lang, K::NoClosedMilestones) } else { g::t(lang, K::NoOpenMilestones) })

    }
      for row in rows.into_iter() { milestone_row(lang: lang, row: row, csrf: row_csrf.clone(), can_triage: can_triage) }
    if can_create { milestone_create_form(lang: lang, csrf: csrf.clone(), action: create_action) } }) } /// Creates a milestone with an optional due date. #[page(POST "/{owner}/{repo}/milestones")] async fn milestone_create(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; triage_guard(cx, &ctx, &form.csrf).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .create_milestone( &ctx.repo.id, &form.title, &form.description, due_on_field(&form.due_on), ) .await; match result { Ok(_) => Err(see_other(back).into()), Err(e) => { let message = refusal_message(e)?; Ok(view! { shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: back)) }) } } } /// The edit page: title, description, due date and state. Triage holders only. #[page("/{owner}/{repo}/milestones/{milestone_id}/edit")] async fn milestone_edit_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); require_user(cx).await?; if !can_triage(&ctx) { return Err(forbidden().into()); } let Some(milestone) = app_context::(cx) .store .milestone_by_id(&ctx.repo.id, id) .await? else { return Err(not_found().into()); }; let frame = RepoFrame::new(&ctx, app_context::(cx), "issues").await?; let page_title = g::milestone_edit_title(lang, &frame.title); let action = format!("{}/{}/edit", milestones_href(&frame.title), milestone.id); let due = milestone.due_on.clone().unwrap_or_default(); let is_open = milestone.state == IssueState::Open; Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), milestone_edit_body(lang: lang, frame: frame, action: action, title_value: milestone.title, description: milestone.description, due: due, is_open: is_open)) }) } /// The edit form body. #[component] async fn milestone_edit_body( lang: Lang, frame: RepoFrame, action: String, title_value: String, description: String, due: String, is_open: bool, ) -> Result { let back = milestones_href(&frame.title); let csrf = frame.csrf.clone(); let header = frame.head.clone(); Ok(view! { repo_head(head: header) issue_subnav(lang: lang, title: frame.title.clone(), active: "milestones".to_owned())

    (g::t(lang, K::EditMilestone))

    (g::t(lang, K::BackToMilestones))

    }) } /// Saves the edit form. A state of `closed` closes the milestone; anything else reopens it. #[page(POST "/{owner}/{repo}/milestones/{milestone_id}/edit")] async fn milestone_edit_save(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; triage_guard(cx, &ctx, &form.csrf).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let state = if form.state == "closed" { IssueState::Closed } else { IssueState::Open }; let result = app_context::(cx) .store .update_milestone( &ctx.repo.id, id, &form.title, &form.description, due_on_field(&form.due_on), state, ) .await; match result { Ok(()) => Err(see_other(back).into()), Err(e) => { let message = refusal_message(e)?; Ok(view! { shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: back)) }) } } } /// Deletes a milestone. Its issues stay open or closed; they just lose the milestone. #[page(POST "/{owner}/{repo}/milestones/{milestone_id}/delete")] async fn milestone_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; triage_guard(cx, &ctx, &form.csrf).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .delete_milestone(&ctx.repo.id, id) .await; match result { Ok(()) => Err(see_other(back).into()), Err(e) => { let message = refusal_message(e)?; Ok(view! { shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: back)) }) } } }