//! Page: the pull request list at `/{owner}/{repo}/pulls`. Filters, sorting and paging work like //! the issue list (`issues_list.rs`), and the helpers come from there. Pull requests are issues //! with `kind = 'pull'`, so the store lists only those. Closed pull requests that were merged //! show a purple "Merged" badge instead of "Closed". use std::collections::HashMap; use forge_core::tz::format_in_tz; use forge_core::{IssueListItem, IssueState}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::{page, path_param, query_params}; use topcoat::view::{View, view}; use crate::i18n::pulls::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::issues_list::{ AssigneeView, LabelView, ListQuery, MilestoneView, Selection, SortOption, list_href, stamp_unix, state_tab_class, }; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; use crate::views::{avatar_url, iso_time, now_unix, relative_time, repo_href, shell}; path_param!(owner); path_param!(repo); /// Pull requests per list page. Same as the issue list. const PER_PAGE: u32 = 25; /// One pull request row, with every string the template shows already built. struct PullRowView { state_class: &'static str, state_label: &'static str, badge_class: &'static str, badge_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, } /// The row's icon and badge: green for open, purple for merged, red for closed without a merge. fn state_parts( lang: Lang, open: bool, merged: bool, ) -> (&'static str, &'static str, &'static str, &'static str) { if open { ( "issue-state issue-state-open", g::t(lang, K::PrStateOpenLabel), "issue-badge issue-badge-open", g::t(lang, K::StateOpen), ) } else if merged { ( "issue-state pull-state-merged", g::t(lang, K::PrStateMergedLabel), "issue-badge issue-badge-merged", g::t(lang, K::StateMerged), ) } else { ( "issue-state pull-state-closed", g::t(lang, K::PrStateClosedLabel), "issue-badge issue-badge-closed", g::t(lang, K::StateClosed), ) } } /// The values every row of one list page shares. struct RowEnv<'a> { lang: Lang, /// The list URL (`/{owner}/{repo}/pulls`); each item links below it. base: &'a str, state: &'a str, milestones: &'a HashMap, now: i64, tz: &'a str, } /// One row, built from the page's shared values and the item's own `merged` flag. fn row_view(env: &RowEnv<'_>, item: &IssueListItem, merged: bool) -> PullRowView { let RowEnv { lang, base, state, milestones, now, tz, } = *env; let issue = &item.issue; let unix = stamp_unix(&issue.created_at); let open = matches!(issue.state, IssueState::Open); let comments = issue.comment_count.max(0); let (state_class, state_label, badge_class, badge_label) = state_parts(lang, open, merged); PullRowView { state_class, state_label, badge_class, badge_label, number_text: format!("#{}", issue.number), href: format!("{base}/{}", issue.number), 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(env.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::comments(lang, comments), } } #[page("/{owner}/{repo}/pulls")] async fn pulls_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(|(user, _)| user)); let (items, total) = state.store.list_pulls(&ctx.repo.id, &sel.filter()).await?; let (open_count, closed_count) = state.store.pull_counts(&ctx.repo.id).await?; let numbers: Vec = items.iter().map(|item| item.issue.number).collect(); let merged = state.store.merged_numbers(&ctx.repo.id, &numbers).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, "pulls", ""); let now = now_unix(); let tz = ctx.viewer_tz().to_owned(); let env = RowEnv { lang, base: &base, state: sel.state, milestones: &milestones, now, tz: &tz, }; let rows: Vec = items .iter() .map(|item| row_view(&env, item, merged.contains(&item.issue.number))) .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_number(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 compare_href = repo_href(&owner_name, &repo_name, "compare", ""); let labels_href = repo_href(&owner_name, &repo_name, "labels.css", ""); let title = g::list_title(lang, &owner_name, &repo_name); let empty_text = if open_count + closed_count == 0 { g::t(lang, K::NoPullRequestsYet) } else { g::t(lang, K::NoPullRequestsMatch) }; let state_word = sel.state; let header = ctx.head(state, "pulls").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 rows.is_empty() {

(empty_text)

} else {
    for row in rows.iter() {
  • (row.title.as_str()) (row.badge_label) for chip in row.labels.iter() { (chip.name.as_str()) }
    (row.number_text.as_str()) if lang == Lang::Tr { (g::t(lang, K::MetaBeforeTime)) (row.author.as_str()) (g::t(lang, K::MetaAfterTime)) } else { (g::t(lang, K::MetaBeforeTime)) (g::t(lang, K::MetaAfterTime)) (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()) }
  • }
} ) }) } #[cfg(test)] mod tests { use forge_core::{IssueState, StateReason, Visibility}; use crate::pages::issues_list::test_util::{OWNER, REPO, fixture, send}; fn base() -> String { format!("/{OWNER}/{REPO}/pulls") } fn rows(html: &str) -> usize { html.matches("class=\"issue-row\"").count() } const HEAD_OID: &str = "1111111111111111111111111111111111111111"; const BASE_OID: &str = "2222222222222222222222222222222222222222"; #[tokio::test] async fn empty_repo_says_no_pull_requests_yet() { 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 pull requests yet")); assert_eq!(rows(&reply.text()), 0); } #[tokio::test] async fn list_shows_pulls_not_issues() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); fx.store .create_issue(&repo_id, &fx.owner.id, "plain issue title", "") .await .unwrap(); fx.store .create_pull( &repo_id, &fx.owner.id, "pull request title", "", "main", &repo_id, "feature", HEAD_OID, BASE_OID, ) .await .unwrap(); let html = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert_eq!(rows(&html), 1, "{html}"); assert!(html.contains("pull request title")); assert!(!html.contains("plain issue title")); assert!( html.contains(&format!("href=\"/{OWNER}/{REPO}/pulls/2\"")), "{html}" ); assert!(html.contains("issue-badge-open\">Open<")); assert!(html.contains(&format!("href=\"/{OWNER}/{REPO}/compare\""))); } #[tokio::test] async fn closed_filter_shows_merged_and_closed_badges() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); let merged = fx .store .create_pull( &repo_id, &fx.owner.id, "merged pull", "", "main", &repo_id, "merged-branch", HEAD_OID, BASE_OID, ) .await .unwrap(); fx.store .mark_merged(&merged.issue.id, &fx.owner.id, BASE_OID) .await .unwrap(); let dropped = fx .store .create_pull( &repo_id, &fx.owner.id, "dropped pull", "", "main", &repo_id, "dropped-branch", HEAD_OID, BASE_OID, ) .await .unwrap(); fx.store .set_issue_state( &dropped.issue.id, Some(fx.owner.id.as_str()), IssueState::Closed, Some(StateReason::NotPlanned), serde_json::json!({}), ) .await .unwrap(); fx.store .create_pull( &repo_id, &fx.owner.id, "still open pull", "", "main", &repo_id, "open-branch", HEAD_OID, BASE_OID, ) .await .unwrap(); let closed = send( &fx.router, "GET", &format!("{}?state=closed", base()), true, None, &[], ) .await .text(); assert!(closed.contains("merged pull"), "{closed}"); assert!(closed.contains("dropped pull"), "{closed}"); assert!(!closed.contains("still open pull"), "{closed}"); assert!(closed.contains("issue-badge-merged\">Merged<"), "{closed}"); assert!(closed.contains("issue-badge-closed\">Closed<"), "{closed}"); assert_eq!(closed.matches("issue-badge-merged\">Merged<").count(), 1); let open = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert!(open.contains("still open pull")); assert!(!open.contains("merged pull")); assert!(!open.contains("issue-badge-merged")); } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let fx = fixture(Visibility::Private).await; let anon = send(&fx.router, "GET", &base(), false, None, &[]).await; assert_eq!(anon.status, 404); let owner = send(&fx.router, "GET", &base(), true, None, &[]).await; assert_eq!(owner.status, 200); } #[tokio::test] async fn new_pull_request_button_only_for_signed_in_viewers() { let fx = fixture(Visibility::Public).await; let button = format!("href=\"/{OWNER}/{REPO}/compare\""); let signed_in = send(&fx.router, "GET", &base(), true, None, &[]) .await .text(); assert!(signed_in.contains("New pull request")); assert!(signed_in.contains(&button)); let anon = send(&fx.router, "GET", &base(), false, None, &[]) .await .text(); assert!(!anon.contains("New pull request")); } #[tokio::test] async fn issues_list_still_excludes_pulls() { let fx = fixture(Visibility::Public).await; let repo_id = fx.repo.id.clone(); fx.store .create_issue(&repo_id, &fx.owner.id, "plain issue title", "") .await .unwrap(); fx.store .create_pull( &repo_id, &fx.owner.id, "pull request title", "", "main", &repo_id, "feature", HEAD_OID, BASE_OID, ) .await .unwrap(); let html = send( &fx.router, "GET", &format!("/{OWNER}/{REPO}/issues?state=all"), true, None, &[], ) .await .text(); assert!(html.contains("plain issue title")); assert!(!html.contains("pull request title"), "{html}"); } }