//! Pages: the issue list at `/{owner}/{repo}/issues` and the new-issue form at //! `/{owner}/{repo}/issues/new` (GET shows the form, POST creates the issue and redirects to it). //! Filters come from the query string. Invalid values fall back to the defaults, never an error. use std::collections::HashMap; use forge_core::tz::format_in_tz; use forge_core::{ IssueFilter, IssueListItem, IssueSort, IssueState, validate_body, validate_title, }; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::RawForm; use topcoat::router::error::{forbidden, see_other}; use topcoat::router::{StatusCode, page, path_param, query_params}; use topcoat::view::{View, component, view}; use crate::auth::{require_csrf, require_user}; use crate::i18n::issues::{self as g, K}; use crate::i18n::{self, Lang}; use crate::notify::{self, Activity}; use crate::pages::issue_meta::{can_triage, form_all, form_field, form_pairs}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoHead, load_repo}; use crate::state::AppState; use crate::templates::{self, Template}; use crate::views::{ alert, avatar_url, csrf_field, enc, iso_time, now_unix, relative_time, repo_href, shell, user_facing_message, }; use crate::webhooks::emit::{Sender, emit_issue}; path_param!(owner); path_param!(repo); /// Issues per list page. const PER_PAGE: u32 = 25; /// Highest accepted `?page=` value. Anything else falls back to page 1. const MAX_PAGE: u32 = 1000; /// Longest accepted filter value, in characters. Longer values are cut. const MAX_FILTER_CHARS: usize = 256; #[query_params(error = bad_request)] pub(crate) struct ListQuery { state: Option, label: Option, milestone: Option, author: Option, assignee: Option, q: Option, sort: Option, page: Option, } /// The list's filters as the page shows them. Every value here is already a valid choice. pub(crate) struct Selection { /// `open`, `closed` or `all`. pub(crate) state: &'static str, pub(crate) label: String, pub(crate) milestone: String, pub(crate) author: String, pub(crate) assignee: String, pub(crate) query: String, /// `newest`, `oldest` or `updated`. pub(crate) sort: &'static str, pub(crate) page: u32, } /// Trimmed and cut to `MAX_FILTER_CHARS`. A missing value is empty. fn clean(value: Option<&str>) -> String { value .unwrap_or_default() .trim() .chars() .take(MAX_FILTER_CHARS) .collect() } /// `Some` for a non-empty filter value. fn non_empty(value: &str) -> Option { (!value.is_empty()).then(|| value.to_owned()) } impl Selection { pub(crate) fn parse(query: &ListQuery) -> Self { Self { state: match query.state.as_deref() { Some("closed") => "closed", Some("all") => "all", _ => "open", }, label: clean(query.label.as_deref()), milestone: clean(query.milestone.as_deref()), author: clean(query.author.as_deref()), assignee: clean(query.assignee.as_deref()), query: clean(query.q.as_deref()), sort: match query.sort.as_deref() { Some("oldest") => "oldest", Some("updated") => "updated", _ => "newest", }, page: query .page .as_deref() .and_then(|p| p.trim().parse::().ok()) .filter(|p| (1..=MAX_PAGE).contains(p)) .unwrap_or(1), } } pub(crate) fn filter(&self) -> IssueFilter { IssueFilter { state: match self.state { "closed" => Some(IssueState::Closed), "all" => None, _ => Some(IssueState::Open), }, label: non_empty(&self.label), milestone: non_empty(&self.milestone), author: non_empty(&self.author), assignee: non_empty(&self.assignee), query: non_empty(&self.query), sort: match self.sort { "oldest" => IssueSort::Oldest, "updated" => IssueSort::RecentlyUpdated, _ => IssueSort::Newest, }, page: self.page, per_page: PER_PAGE, } } /// Whether any filter besides the state is set. pub(crate) fn has_filters(&self) -> bool { !(self.label.is_empty() && self.milestone.is_empty() && self.author.is_empty() && self.assignee.is_empty() && self.query.is_empty()) } /// The list URL with this selection, but a different state and page. pub(crate) fn href(&self, base: &str, state: &str, page: u32) -> String { let page_text = if page > 1 { page.to_string() } else { String::new() }; let sort = if self.sort == "newest" { "" } else { self.sort }; list_href( base, &[ ("state", state), ("label", self.label.as_str()), ("milestone", self.milestone.as_str()), ("author", self.author.as_str()), ("assignee", self.assignee.as_str()), ("q", self.query.as_str()), ("sort", sort), ("page", page_text.as_str()), ], ) } } /// `base` followed by a query string holding the non-empty parameters, percent-encoded. pub(crate) fn list_href(base: &str, params: &[(&str, &str)]) -> String { let query: Vec = params .iter() .filter(|(_, value)| !value.is_empty()) .map(|(key, value)| format!("{key}={}", enc(value))) .collect(); if query.is_empty() { base.to_owned() } else { format!("{base}?{}", query.join("&")) } } /// Unix seconds of a stored RFC 3339 stamp. An unreadable stamp gives 0. pub(crate) fn stamp_unix(stamp: &str) -> i64 { time::OffsetDateTime::parse(stamp, &time::format_description::well_known::Rfc3339) .map_or(0, |t| t.unix_timestamp()) } /// Tab class for the state links. pub(crate) fn state_tab_class(on: bool) -> &'static str { if on { "issue-tab current" } else { "issue-tab" } } pub(crate) struct AssigneeView { pub(crate) name: String, pub(crate) avatar: String, } pub(crate) struct LabelView { pub(crate) class: String, pub(crate) name: String, pub(crate) href: String, } #[derive(Clone)] pub(crate) struct MilestoneView { pub(crate) title: String, pub(crate) href: String, } /// One issue row, with every string the template shows already built. struct RowView { state_class: &'static str, state_label: &'static str, number_text: String, href: String, title: String, labels: Vec, author: String, relative: String, absolute: String, iso: String, milestone: Option, assignees: Vec, has_comments: bool, comments_text: String, } /// What every row of one list page shares: the language, the repo, the state filter, the /// milestone titles by id, and the clock and timezone for relative times. #[derive(Clone, Copy)] struct RowCtx<'a> { lang: Lang, owner: &'a str, repo: &'a str, state: &'a str, milestones: &'a HashMap, now: i64, tz: &'a str, } fn row_view(row: RowCtx<'_>, item: &IssueListItem) -> RowView { let RowCtx { lang, owner, repo, state, milestones, now, tz, } = row; let issue = &item.issue; let base = repo_href(owner, repo, "issues", ""); let unix = stamp_unix(&issue.created_at); let open = matches!(issue.state, IssueState::Open); let comments = issue.comment_count.max(0); RowView { state_class: if open { "issue-state issue-state-open" } else { "issue-state issue-state-closed" }, state_label: if open { g::t(lang, K::OpenIssue) } else { g::t(lang, K::ClosedIssue) }, number_text: format!("#{}", issue.number), href: repo_href(owner, repo, "issues", &issue.number.to_string()), title: issue.title.clone(), labels: item .labels .iter() .map(|label| LabelView { class: format!("label-chip label-{}", label.id), name: label.name.clone(), href: list_href(&base, &[("state", state), ("label", label.name.as_str())]), }) .collect(), author: item.author.clone(), relative: relative_time(row.lang, unix, now), absolute: format_in_tz(unix, tz), iso: iso_time(unix), milestone: issue .milestone_id .as_ref() .and_then(|id| milestones.get(id)) .map(|title| MilestoneView { title: title.clone(), href: list_href(&base, &[("state", state), ("milestone", title.as_str())]), }), assignees: item .assignees .iter() .map(|name| AssigneeView { name: name.clone(), avatar: avatar_url(name, None), }) .collect(), has_comments: comments > 0, comments_text: g::comment_count(lang, comments), } } /// One pinned issue card, with the strings the template shows already built. struct PinnedCard { state_class: &'static str, state_label: &'static str, number_text: String, href: String, title: String, comments_text: String, } fn pinned_card(lang: Lang, owner: &str, repo: &str, issue: &forge_core::Issue) -> PinnedCard { let open = matches!(issue.state, IssueState::Open); let comments = issue.comment_count.max(0); PinnedCard { state_class: if open { "issue-state issue-state-open" } else { "issue-state issue-state-closed" }, state_label: if open { g::t(lang, K::OpenIssue) } else { g::t(lang, K::ClosedIssue) }, number_text: format!("#{}", issue.number), href: repo_href(owner, repo, "issues", &issue.number.to_string()), title: issue.title.clone(), comments_text: g::comment_count(lang, comments), } } /// One option of the sort select. pub(crate) struct SortOption { pub(crate) value: &'static str, pub(crate) label: &'static str, pub(crate) selected: bool, } #[page("/{owner}/{repo}/issues")] async fn issues_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let query = query_params::(cx)?; let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let sel = Selection::parse(query); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let (items, total) = state.store.list_issues(&ctx.repo.id, &sel.filter()).await?; let pinned: Vec = state .store .pinned_issues(&ctx.repo.id) .await? .iter() .map(|issue| pinned_card(lang, &ctx.repo.owner_name, &ctx.repo.name, issue)) .collect(); let (open_count, closed_count) = state.store.issue_counts(&ctx.repo.id).await?; let milestones: HashMap = state .store .list_milestones(&ctx.repo.id, None) .await? .into_iter() .map(|m| (m.id, m.title)) .collect(); let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let base = repo_href(&owner_name, &repo_name, "issues", ""); let now = now_unix(); let tz = ctx.viewer_tz().to_owned(); let row = RowCtx { lang, owner: &owner_name, repo: &repo_name, state: sel.state, milestones: &milestones, now, tz: &tz, }; let rows: Vec = items.iter().map(|item| row_view(row, item)).collect(); let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1); let has_newer = sel.page > 1; let has_older = u64::from(sel.page) < last_page; let newer_href = sel.href(&base, sel.state, sel.page.saturating_sub(1)); let older_href = sel.href(&base, sel.state, sel.page + 1); let page_label = g::page_label(lang, sel.page); let open_href = sel.href(&base, "open", 1); let closed_href = sel.href(&base, "closed", 1); let all_href = sel.href(&base, "all", 1); let open_text = g::tab_open(lang, open_count); let closed_text = g::tab_closed(lang, closed_count); let all_text = g::tab_all(lang, open_count + closed_count); let clear_href = list_href(&base, &[("state", sel.state)]); let has_filters = sel.has_filters(); let sort_options: Vec = [ ("newest", g::t(lang, K::SortNewest)), ("oldest", g::t(lang, K::SortOldest)), ("updated", g::t(lang, K::SortUpdated)), ] .into_iter() .map(|(value, label)| SortOption { value, label, selected: value == sel.sort, }) .collect(); let can_create = ctx.viewer.is_some() && !ctx.repo.archived; let new_href = repo_href(&owner_name, &repo_name, "issues/new", ""); let labels_href = repo_href(&owner_name, &repo_name, "labels.css", ""); let labels_page_href = repo_href(&owner_name, &repo_name, "labels", ""); let milestones_href = repo_href(&owner_name, &repo_name, "milestones", ""); let title = g::issues_title(lang, &format!("{owner_name}/{repo_name}")); let empty_text = g::t(lang, K::NoIssuesMatch); let state_word = sel.state; let header = ctx.head(state, "issues").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(labels_href.as_str()), repo_head(head: header) if has_filters {

(g::t(lang, K::FiltersApplied)) (g::t(lang, K::ClearFilters))

} if !pinned.is_empty() {

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

    for card in pinned.iter() {
  • (card.number_text.as_str()) (card.comments_text.as_str())
    (card.title.as_str())
  • }
} if rows.is_empty() {

(empty_text)

} else {
    for row in rows.iter() {
  • (row.title.as_str()) for chip in row.labels.iter() { (chip.name.as_str()) }
    (row.number_text.as_str()) if lang == Lang::Tr { (row.author.as_str()) (g::t(lang, K::By)) (g::t(lang, K::Opened)) } else { (g::t(lang, K::Opened)) (g::t(lang, K::By)) (row.author.as_str()) } if let Some(milestone) = row.milestone.clone() { (milestone.title.as_str()) }
    for assignee in row.assignees.iter() { (assignee.name.as_str()) } if row.has_comments { (row.comments_text.as_str()) }
  • }
} ) }) } /// The values the new-issue form shows: what was typed, and the message when validation failed. #[derive(Default)] struct DraftIssue { title: String, body: String, error: Option, } /// The query of the new-issue page. `template` is a template's stem. `blank` opens an empty /// form. Without either, a repo with several templates shows the chooser. #[query_params(error = bad_request)] struct NewIssueQuery { template: Option, blank: Option, } /// One row of the template chooser. struct ChooserRow { href: String, name: String, about: String, } /// One label checkbox on the new-issue form. Only viewers who may triage get them. struct LabelOption { id: String, name: String, checked: bool, } /// Checkboxes for the repo's labels, checked where `checked` says so. fn label_options( labels: &[forge_core::Label], checked: impl Fn(&forge_core::Label) -> bool, ) -> Vec { labels .iter() .map(|label| LabelOption { id: label.id.clone(), name: label.name.clone(), checked: checked(label), }) .collect() } /// Whether the template suggests this label. Names compare without case. fn suggested(template: Option<&Template>, label: &forge_core::Label) -> bool { template.is_some_and(|t| { t.labels .iter() .any(|name| name.eq_ignore_ascii_case(&label.name)) }) } /// Validates a title and body. `Ok(Err(message))` is a rejection the form can show. The title is /// returned trimmed. fn check_draft(title: &str, body: &str) -> forge_core::Result> { let title = match validate_title(title) { Ok(title) => title, Err(e) => return user_facing_message(&e).map(Err).ok_or(e), }; match validate_body(body) { Ok(()) => Ok(Ok(title)), Err(e) => user_facing_message(&e).map(Err).ok_or(e), } } /// The body of the new-issue page: the repo header with Issues active, then either the template /// chooser (`chooser` is `Some`) or the form. #[component] async fn new_issue_body( lang: Lang, head: RepoHead, csrf: String, draft: DraftIssue, labels: Vec, chooser: Option>, ) -> Result { let action = repo_href(&head.owner, &head.name, "issues/new", ""); let cancel = repo_href(&head.owner, &head.name, "issues", ""); let blank = format!("{action}?blank=1"); let chooser_mode = chooser.is_some(); let rows = chooser.unwrap_or_default(); let has_labels = !labels.is_empty(); Ok(view! { repo_head(head: head)

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

if chooser_mode {

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

    for row in rows.iter() {
  • (row.name.as_str()) if !row.about.is_empty() {
    (row.about.as_str())
    }
  • }
} else { if let Some(message) = draft.error { alert(message: message.as_str()) }
csrf_field(token: csrf.as_str()) if has_labels {
(g::t(lang, K::Labels)) for label in labels.iter() { }
}
(g::t(lang, K::Cancel))
}
}) } #[page("/{owner}/{repo}/issues/new")] async fn new_issue_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let query = query_params::(cx)?; let ctx = load_repo(cx, owner, name).await?; let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let header = ctx.head(state, "issues").await?; let title = g::new_issue_title(lang, &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let csrf = viewer.1.csrf_token.clone(); let found = templates::load_issue_templates(ctx.path.clone(), ctx.repo.default_branch.clone()).await?; let chooser = if found.len() > 1 && query.template.is_none() && query.blank.is_none() { let base = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "issues/new", ""); Some( found .iter() .map(|t| ChooserRow { href: format!("{base}?template={}", enc(&t.stem)), name: t.name.clone(), about: t.about.clone(), }) .collect(), ) } else { None }; let selected = match (query.blank.is_some(), query.template.as_deref()) { (true, _) => None, (false, Some(stem)) => found.iter().find(|t| t.stem == stem), (false, None) => found.first().filter(|_| found.len() == 1), }; let draft = selected .map(|t| DraftIssue { title: t.title.clone(), body: t.body.clone(), error: None, }) .unwrap_or_default(); let labels = if can_triage(&ctx) { let repo_labels = state.store.list_labels(&ctx.repo.id).await?; label_options(&repo_labels, |l| suggested(selected, l)) } else { Vec::new() }; Ok(view! { shell(title: title.as_str(), viewer: Some(&viewer), new_issue_body(lang: lang, head: header, csrf: csrf, draft: draft, labels: labels, chooser: chooser)) }) } #[page(POST "/{owner}/{repo}/issues/new")] async fn new_issue_submit(cx: &Cx, RawForm(raw): RawForm) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let fields = form_pairs(&raw[..]); let viewer = require_csrf(cx, &form_field(&fields, "csrf")).await?; let lang = i18n::lang(cx, Some(&viewer.0)); if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let typed_title = form_field(&fields, "title"); let typed_body = form_field(&fields, "body"); let picked = form_all(&fields, "label"); match check_draft(&typed_title, &typed_body)? { Ok(title) => { let issue = state .store .create_issue(&ctx.repo.id, &viewer.0.id, &title, &typed_body) .await?; if can_triage(&ctx) && !picked.is_empty() { let repo_labels = state.store.list_labels(&ctx.repo.id).await?; let ids: Vec = repo_labels .iter() .filter(|l| picked.contains(&l.id)) .map(|l| l.id.clone()) .collect(); state .store .set_issue_labels(&issue.id, &viewer.0.id, &ids) .await?; } emit_issue(state, &ctx.repo, &Sender::of(&viewer.0), "opened", &issue).await; notify::dispatch( state, &ctx.repo, &issue, &viewer.0.id, Activity::Opened, Some(&issue.body), ) .await; let location = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "issues", &issue.number.to_string(), ); Err(see_other(location).into()) } Err(message) => { let header = ctx.head(state, "issues").await?; let title = g::new_issue_title(lang, &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); let csrf = viewer.1.csrf_token.clone(); let draft = DraftIssue { title: typed_title, body: typed_body, error: Some(message), }; let labels = if can_triage(&ctx) { let repo_labels = state.store.list_labels(&ctx.repo.id).await?; label_options(&repo_labels, |l| picked.contains(&l.id)) } else { Vec::new() }; Ok(view! { (StatusCode::UNPROCESSABLE_ENTITY) shell(title: title.as_str(), viewer: Some(&viewer), new_issue_body(lang: lang, head: header, csrf: csrf, draft: draft, labels: labels, chooser: None)) }) } } } /// Shared test fixture for the issue and label pages: a store with one owner, one repo and a /// signed-in session for the owner. The pages need no git data, so no repository is created. #[cfg(test)] pub(crate) mod test_util { use forge_core::{Repo, Store, User, Visibility}; use http::HeaderMap; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; pub const OWNER: &str = "alice"; pub const REPO: &str = "demo"; pub const TOKEN: &str = "issues-test-session"; pub const CSRF: &str = "issues-test-csrf"; pub struct Fixture { pub router: Router, pub store: Store, pub owner: User, pub repo: Repo, _dir: tempfile::TempDir, } pub async fn fixture(visibility: Visibility) -> Fixture { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().to_path_buf(); let state = crate::test_support::test_state(&path).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); let owner = store .create_user(OWNER, None, "not-a-real-hash", false) .await .unwrap(); let repo = store .create_repo(&owner.id, REPO, "", visibility, "main") .await .unwrap(); crate::test_support::create_session(&store, &owner.id, TOKEN, CSRF).await; Fixture { router, store, owner, repo, _dir: dir, } } /// One response: status, headers and body text. pub struct Reply { pub status: u16, pub headers: HeaderMap, pub body: String, } impl Reply { pub fn header(&self, name: &str) -> Option { self.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) } pub fn text(&self) -> String { self.body.clone() } } /// Sends one request through the router. `signed_in` adds the owner's session cookie, and /// `form` is sent as a urlencoded body. pub async fn send( router: &Router, method: &str, uri: &str, signed_in: bool, form: Option<&str>, headers: &[(&str, &str)], ) -> Reply { let mut builder = Request::builder().method(method).uri(uri); if signed_in { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}")); } for (name, value) in headers { builder = builder.header(*name, *value); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); form.as_bytes().to_vec() } None => Vec::new(), }; let res = router.handle(builder.body(Body::from(body)).unwrap()).await; let status = res.status().as_u16(); let headers = res.headers().clone(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); Reply { status, headers, body: String::from_utf8_lossy(&bytes).into_owned(), } } } #[cfg(test)] mod tests { use forge_core::{IssueState, StateReason, Visibility}; use super::test_util::{OWNER, REPO, fixture, send}; fn base() -> String { format!("/{OWNER}/{REPO}/issues") } /// Number of issue rows in a page. fn rows(html: &str) -> usize { html.matches("class=\"issue-row\"").count() } #[tokio::test] async fn toolbar_links_to_labels_and_milestones() { let fx = fixture(Visibility::Public).await; let labels = format!("href=\"/{OWNER}/{REPO}/labels\""); let milestones = format!("href=\"/{OWNER}/{REPO}/milestones\""); for signed_in in [true, false] { let html = send(&fx.router, "GET", &base(), signed_in, None, &[]) .await .text(); assert!(html.contains(&labels), "signed_in={signed_in}"); assert!(html.contains(&milestones), "signed_in={signed_in}"); } } #[tokio::test] async fn empty_list_says_so() { let fx = fixture(Visibility::Public).await; let reply = send(&fx.router, "GET", &base(), true, None, &[]).await; assert_eq!(reply.status, 200); assert!(reply.text().contains("No issues match these filters.")); assert_eq!(rows(&reply.text()), 0); } #[tokio::test] async fn newest_first_and_post_creates_an_issue() { let fx = fixture(Visibility::Public).await; let reply = send( &fx.router, "POST", &format!("{}/new", base()), true, Some("csrf=issues-test-csrf&title=+first+issue+&body=hello+**there**"), &[], ) .await; assert_eq!(reply.status, 303); assert_eq!( reply.header("location").as_deref(), Some(format!("{}/1", base()).as_str()) ); let repo_id = fx.repo.id.clone(); fx.store .create_issue(&repo_id, &fx.owner.id, "second issue", "") .await .unwrap(); fx.store .create_issue(&repo_id, &fx.owner.id, "third issue", "") .await .unwrap(); let html = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert_eq!(rows(&html), 3); let third = html.find("third issue").unwrap(); let second = html.find("second issue").unwrap(); let first = html.find("first issue").unwrap(); assert!(third < second && second < first, "{html}"); } #[tokio::test] async fn pagination_with_thirty_issues() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); for n in 1..=30 { fx.store .create_issue(&repo_id, &fx.owner.id, &format!("bulk {n:02}"), "") .await .unwrap(); } let page1 = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert_eq!(rows(&page1), 25); assert!(page1.contains(">Older<")); assert!(!page1.contains(">Newer<")); let page2 = send( &fx.router, "GET", &format!("{}?page=2", base()), true, None, &[], ) .await .text(); assert_eq!(rows(&page2), 5); assert!(page2.contains(">Newer<")); assert!(!page2.contains(">Older<")); assert!(page2.contains("bulk 01")); } #[tokio::test] async fn state_filter_and_counts() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); let open = fx .store .create_issue(&repo_id, &fx.owner.id, "still open", "") .await .unwrap(); let done = fx .store .create_issue(&repo_id, &fx.owner.id, "already done", "") .await .unwrap(); fx.store .set_issue_state( &done.id, Some(fx.owner.id.as_str()), IssueState::Closed, Some(StateReason::Completed), serde_json::json!({}), ) .await .unwrap(); assert_eq!(open.number, 1); let default = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert!(default.contains("still open")); assert!(!default.contains("already done")); assert!(default.contains("Closed 1"), "{default}"); let closed = send( &fx.router, "GET", &format!("{}?state=closed", base()), true, None, &[], ) .await .text(); assert!(closed.contains("already done")); assert!(!closed.contains("still open")); let all = send( &fx.router, "GET", &format!("{}?state=all", base()), true, None, &[], ) .await .text(); assert!(all.contains("already done") && all.contains("still open")); let bogus = send( &fx.router, "GET", &format!("{}?state=bogus", base()), true, None, &[], ) .await; assert_eq!(bogus.status, 200); assert!(bogus.text().contains("still open")); } #[tokio::test] async fn label_filter_uses_store_labels() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); let bug = fx .store .create_label(&repo_id, "bug", "#d73a4a", "") .await .unwrap(); let tagged = fx .store .create_issue(&repo_id, &fx.owner.id, "tagged issue", "") .await .unwrap(); fx.store .create_issue(&repo_id, &fx.owner.id, "plain issue", "") .await .unwrap(); fx.store .set_issue_labels(&tagged.id, &fx.owner.id, std::slice::from_ref(&bug.id)) .await .unwrap(); let filtered = send( &fx.router, "GET", &format!("{}?label=bug", base()), true, None, &[], ) .await .text(); assert!(filtered.contains("tagged issue")); assert!(!filtered.contains("plain issue")); assert!(filtered.contains(&format!("label-chip label-{}", bug.id))); assert!(filtered.contains(&format!("/{OWNER}/{REPO}/labels.css"))); } #[tokio::test] async fn search_treats_percent_as_a_literal() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); for title in ["100% done", "100 done", "other thing"] { fx.store .create_issue(&repo_id, &fx.owner.id, title, "") .await .unwrap(); } let html = send( &fx.router, "GET", &format!("{}?q=%25", base()), true, None, &[], ) .await .text(); assert!(html.contains(">100% done"), "{html}"); assert!(!html.contains(">100 done"), "{html}"); assert!(!html.contains(">other thing"), "{html}"); let raw = send( &fx.router, "GET", &format!("{}?q=100%", base()), true, None, &[], ) .await; assert_eq!(raw.status, 200); } #[tokio::test] async fn anonymous_reads_public_but_cannot_create() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); fx.store .create_issue(&repo_id, &fx.owner.id, "public issue", "") .await .unwrap(); let list = send(&fx.router, "GET", &base(), false, None, &[]).await; assert_eq!(list.status, 200); assert!(list.text().contains("public issue")); assert!(!list.text().contains("New issue")); let form = send( &fx.router, "GET", &format!("{}/new", base()), false, None, &[], ) .await; assert_eq!(form.status, 303); assert!(form.header("location").unwrap().starts_with("/login")); let post = send( &fx.router, "POST", &format!("{}/new", base()), false, Some("csrf=&title=sneaky"), &[], ) .await; assert_eq!(post.status, 303); assert!(post.header("location").unwrap().starts_with("/login")); let (total, _) = fx.store.issue_counts(&repo_id).await.unwrap(); assert_eq!(total, 1); } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let fx = fixture(Visibility::Private).await; let list = send(&fx.router, "GET", &base(), false, None, &[]).await; assert_eq!(list.status, 404); let owner = send(&fx.router, "GET", &base(), true, None, &[]).await; assert_eq!(owner.status, 200); } #[tokio::test] async fn invalid_query_values_fall_back_to_defaults() { let fx = fixture(Visibility::Public).await; for query in [ "page=abc", "page=0", "page=99999", "sort=sideways&state=&page=-1", ] { let reply = send( &fx.router, "GET", &format!("{}?{query}", base()), true, None, &[], ) .await; assert_eq!(reply.status, 200, "{query}"); assert!(reply.text().contains("Page 1"), "{query}"); } } #[tokio::test] async fn new_issue_route_wins_over_issue_number() { let fx = fixture(Visibility::Public).await; let reply = send( &fx.router, "GET", &format!("{}/new", base()), true, None, &[], ) .await; assert_eq!(reply.status, 200); assert!(reply.text().contains(&format!("action=\"{}/new\"", base()))); } #[tokio::test] async fn invalid_draft_keeps_values_and_creates_nothing() { let fx = fixture(Visibility::Public).await; let reply = send( &fx.router, "POST", &format!("{}/new", base()), true, Some("csrf=issues-test-csrf&title=+++&body=kept+body"), &[], ) .await; assert_eq!(reply.status, 422); assert!(reply.text().contains("kept body")); let (total, _) = fx.store.issue_counts(&fx.repo.id).await.unwrap(); assert_eq!(total, 0); } #[tokio::test] async fn bad_csrf_is_403_and_archived_repo_is_403() { let fx = fixture(Visibility::Public).await; let bad = send( &fx.router, "POST", &format!("{}/new", base()), true, Some("csrf=wrong&title=hi"), &[], ) .await; assert_eq!(bad.status, 403); fx.store.set_repo_archived(&fx.repo.id, true).await.unwrap(); let archived = send( &fx.router, "POST", &format!("{}/new", base()), true, Some("csrf=issues-test-csrf&title=hi"), &[], ) .await; assert_eq!(archived.status, 403); let form = send( &fx.router, "GET", &format!("{}/new", base()), true, None, &[], ) .await; assert_eq!(form.status, 403); } #[tokio::test] async fn turkish_viewer_sees_turkish_list() { let fx = fixture(Visibility::Public).await; let reply = send( &fx.router, "GET", &base(), true, None, &[("accept-language", "tr")], ) .await; assert_eq!(reply.status, 200); let html = reply.text(); assert!(html.contains(">Yeni sorun"), "{html}"); assert!(html.contains("Bu filtrelerle eşleşen sorun yok."), "{html}"); } #[tokio::test] async fn turkish_viewer_sees_relative_times_in_turkish() { let fx = fixture(Visibility::Public).await; fx.store .create_issue(&fx.repo.id, &fx.owner.id, "fresh issue", "") .await .unwrap(); let html = send( &fx.router, "GET", &base(), true, None, &[("accept-language", "tr")], ) .await .text(); assert!(html.contains("az önce"), "{html}"); assert!(html.contains("açıldı"), "{html}"); assert!(!html.contains("just now"), "{html}"); } }