//! Page: labels (`/{owner}/{repo}/labels`). Anyone who can read the repo sees each label as a chip //! with its description and open issue count. Triage holders also get a create form and, per //! label, edit and delete forms. Writes check CSRF and `Action::Triage` (see `issue_meta`). use std::collections::HashMap; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::see_other; use topcoat::router::{page, path_param}; use topcoat::view::{View, component, view}; use crate::i18n::issues::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::issue_meta::{ RepoFrame, action_error_at, issue_subnav, refusal_message, triage_guard, }; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; use crate::views::{enc, shell}; path_param!(owner); path_param!(repo); path_param!(label_id); /// The create and edit form. Only the fields the form sends are read. #[derive(Deserialize)] struct LabelForm { #[serde(default)] csrf: String, #[serde(default)] name: String, #[serde(default)] color: String, #[serde(default)] description: String, } /// The body of the delete form, which only carries the CSRF token. #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } /// One label as the page shows it. struct LabelRow { name: String, description: String, color: String, chip_class: String, issues_href: String, open_text: String, edit_action: String, delete_action: String, } /// `/{owner}/{repo}/labels` for this repo. fn labels_href(title: &str) -> String { format!("/{title}/labels") } /// The header, the label list and, for triage holders, the forms. #[component] async fn labels_body(lang: Lang, frame: RepoFrame, rows: Vec) -> Result { let labels_action = labels_href(&frame.title); let defaults_action = format!("{labels_action}/defaults"); let empty = rows.is_empty(); let archived = frame.archived; let can_triage = frame.can_triage; 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: "labels".to_owned()) if archived {
(g::t(lang, K::LabelsArchived))
}

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

if empty {

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

if can_triage && !archived {
} }
    for row in rows.iter() {
  • if !row.description.is_empty() {

    (row.description.as_str())

    } if can_triage {
    (g::t(lang, K::Edit))
    }
  • }
if can_triage {

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

} }) } /// Gets the labels page. Hidden repos are 404 (checked in `load_repo`). #[page("/{owner}/{repo}/labels")] async fn labels_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 store = &app_context::(cx).store; let labels = store.list_labels(&ctx.repo.id).await?; let counts: HashMap = store .label_open_counts(&ctx.repo.id) .await? .into_iter() .collect(); let frame = RepoFrame::new(&ctx, app_context::(cx), "issues").await?; let title = frame.title.clone(); let rows: Vec = labels .into_iter() .map(|label| { let open = counts.get(&label.id).copied().unwrap_or(0); LabelRow { chip_class: format!("label-chip label-{}", label.id), issues_href: format!("/{title}/issues?label={}", enc(&label.name)), open_text: g::open_label_count(lang, open), edit_action: format!("{}/{}", labels_href(&title), label.id), delete_action: format!("{}/{}/delete", labels_href(&title), label.id), name: label.name, description: label.description, color: label.color, } }) .collect(); let css_href = format!("/{}/labels.css", frame.title); let page_title = g::labels_title(lang, &frame.title); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(css_href.as_str()), labels_body(lang: lang, frame: frame, rows: rows)) }) } /// Creates a label. A duplicate name is shown on the error page. #[page(POST "/{owner}/{repo}/labels")] async fn label_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 = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .create_label(&ctx.repo.id, &form.name, &form.color, &form.description) .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)) }) } } } /// Updates a label's name, color and description. #[page(POST "/{owner}/{repo}/labels/{label_id}")] async fn label_update(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 = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .update_label(&ctx.repo.id, id, &form.name, &form.color, &form.description) .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)) }) } } } /// Adds the default labels to a repo that has none. Triage holders only; labels that already /// exist are skipped, so a repeated submit adds nothing. #[page(POST "/{owner}/{repo}/labels/defaults")] async fn label_defaults(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 = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .create_default_labels(&ctx.repo.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)) }) } } } /// Deletes a label and its links to issues. #[page(POST "/{owner}/{repo}/labels/{label_id}/delete")] async fn label_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 = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let result = app_context::(cx) .store .delete_label(&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)) }) } } } #[cfg(test)] mod tests { use forge_core::{Role, Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "labels-defaults-csrf"; struct Site { router: Router, store: Store, _dir: tempfile::TempDir, bob: String, carol: String, } /// `alice/forge` (public) with Bob as a Read collaborator and Carol as Triage. async fn site() -> Site { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); let alice = store .create_user("alice", None, "x", false) .await .expect("alice"); let bob = store .create_user("bob", None, "x", false) .await .expect("bob"); let carol = store .create_user("carol", None, "x", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "forge", "", Visibility::Public, "main") .await .expect("repo"); store .set_collaborator(&repo.id, &bob.id, Role::Read) .await .expect("bob role"); store .set_collaborator(&repo.id, &carol.id, Role::Triage) .await .expect("carol role"); crate::test_support::create_session(&store, &bob.id, "bob-token", CSRF).await; crate::test_support::create_session(&store, &carol.id, "carol-token", CSRF).await; Site { router, store, _dir: dir, bob: format!("__Host-{SESSION_COOKIE}=bob-token"), carol: format!("__Host-{SESSION_COOKIE}=carol-token"), } } /// Sends a request and returns the status and body text. async fn send( site: &Site, method: &str, uri: &str, cookie: Option<&str>, csrf: &str, ) -> (u16, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let body = form_urlencoded::Serializer::new(String::new()) .append_pair("csrf", csrf) .finish(); builder = builder.header("content-type", "application/x-www-form-urlencoded"); let res = site .router .handle(builder.body(Body::from(body)).expect("request")) .await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); (status, String::from_utf8_lossy(&bytes).into_owned()) } async fn label_count(site: &Site) -> usize { let repo = site .store .repo_by_owner_and_name("alice", "forge") .await .expect("query") .expect("repo"); site.store .list_labels(&repo.id) .await .expect("labels") .len() } #[tokio::test] async fn triage_adds_defaults_once_and_repeat_adds_nothing() { let site = site().await; let url = "/alice/forge/labels/defaults"; let (status, _) = send(&site, "POST", url, Some(&site.carol), CSRF).await; assert_eq!(status, 303); assert_eq!(label_count(&site).await, 9); let (status, _) = send(&site, "POST", url, Some(&site.carol), CSRF).await; assert_eq!(status, 303); assert_eq!(label_count(&site).await, 9); } #[tokio::test] async fn read_only_viewer_cannot_add_defaults() { let site = site().await; let (status, _) = send( &site, "POST", "/alice/forge/labels/defaults", Some(&site.bob), CSRF, ) .await; assert_eq!(status, 403); assert_eq!(label_count(&site).await, 0); } #[tokio::test] async fn defaults_need_a_valid_csrf_token() { let site = site().await; let (status, _) = send( &site, "POST", "/alice/forge/labels/defaults", Some(&site.carol), "wrong", ) .await; assert_ne!(status, 303); assert_eq!(label_count(&site).await, 0); } #[tokio::test] async fn button_shows_only_for_triage_when_no_labels() { let site = site().await; let (status, body) = send(&site, "GET", "/alice/forge/labels", Some(&site.carol), CSRF).await; assert_eq!(status, 200); assert!(body.contains("/alice/forge/labels/defaults")); let (status, body) = send(&site, "GET", "/alice/forge/labels", Some(&site.bob), CSRF).await; assert_eq!(status, 200); assert!(!body.contains("/labels/defaults")); } }