//! Projects (hub spec §8.2): boards of issues, proposals and notes across //! the repositories of one person or organisation. use super::settings::csrf; use super::{filters, Error, Page, Shell}; use crate::auth::{roles, Access, Viewer}; use crate::db::{self, issues as idb, projects as pdb}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; /// What a caller may do with an owner's projects. pub struct Rights { /// Sees private projects. pub member: bool, /// Creates and changes projects. pub edit: bool, } /// Rights of `account` (anonymous when `None`) over `owner`'s projects; /// `None` when there is no such person or organisation. pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result> { let admin = account.is_some_and(|a| a.is_admin); if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? { let me = account.is_some_and(|v| v.id == a.id); return Ok(Some(Rights { member: me || admin, edit: me || admin })); } if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? { let member = match account { Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(), None => false, }; return Ok(Some(Rights { member: member || admin, edit: member || admin })); } Ok(None) } async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result { rights_of(app, viewer.account(), owner) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone())) } #[derive(Template)] #[template(path = "projects.html")] pub struct ProjectsPage { shell: Shell, owner: String, /// The projects shown: open or closed as `closed` says, matching `q`. projects: Vec, can_edit: bool, error: Option, /// On your own page, the organisations you belong to: their boards /// are one click away, and where a board for one is made (#119). orgs: Vec, /// The title filter, and whether the closed ones are shown (#124). q: String, closed: bool, open_count: usize, closed_count: usize, } impl ProjectsPage { /// The page for `all` the projects the viewer may see: counted open /// and closed among those matching `q`, then the ones asked for. #[allow(clippy::too_many_arguments)] fn build( shell: Shell, owner: String, all: Vec, can_edit: bool, error: Option, orgs: Vec, q: Option, closed: bool, ) -> ProjectsPage { let q = q.unwrap_or_default().trim().to_string(); let needle = q.to_lowercase(); let matching: Vec = all .into_iter() .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle)) .collect(); let closed_count = matching.iter().filter(|p| p.closed).count(); let open_count = matching.len() - closed_count; let projects = matching.into_iter().filter(|p| p.closed == closed).collect(); ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count } } } /// `?q=TEXT&state=open|closed` on the Projects page (#124). #[derive(Deserialize, Default)] pub struct ProjectsQuery { #[serde(default)] q: Option, #[serde(default)] state: Option, } /// The organisations the viewer belongs to, when `owner` is the viewer. async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec { match viewer.account() { Some(a) if a.username == owner => { crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default() } _ => vec![], } } pub async fn list( State(app): State, viewer: Viewer, Path(owner): Path, Query(query): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); let r = rights(&app, &shell, &viewer, &owner).await?; let projects = pdb::of_owner(&app.db, &owner) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .filter(|p| p.public || r.member) .collect(); let orgs = own_orgs(&app, &viewer, &owner).await; let closed = query.state.as_deref() == Some("closed"); Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed))) } #[derive(Deserialize)] pub struct NewProjectForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] description: String, #[serde(default)] private: Option, } pub async fn create( State(app): State, viewer: Viewer, Path(owner): Path, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let r = rights(&app, &shell, &viewer, &owner).await?; if !r.edit { return Err(Error::not_found(shell)); } let title = f.title.trim(); if title.is_empty() || title.len() > 100 { let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; let orgs = own_orgs(&app, &viewer, &owner).await; return Ok(Page(ProjectsPage::build( shell, owner, projects, true, Some("A project needs a title of up to 100 characters.".into()), orgs, None, false, )) .into_response()); } let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none()) .await .map_err(|e| Error::from_db(shell.clone(), e))?; db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response()) } /// A card as the board shows it. #[derive(Clone)] pub struct CardView { pub card: pdb::Card, /// `owner/name#N`, for issue and proposal cards. pub reference: String, pub url: String, pub title: String, /// `open`, `closed`, `landed`, `draft`… for issues and proposals. pub state: String, /// The issue's or proposal's labels, and an issue's assignees (#293). pub labels: Vec, pub assignees: Vec, /// Its repository's name, its milestone's title, and when the issue or /// proposal was opened (a note: when it was added), for the filter's /// `repo:`, `milestone:` and `age:` and for lanes (#361). pub repo: String, pub milestone: String, pub opened_ms: i64, } #[derive(Clone)] pub struct ColumnView { pub column: pdb::Column, pub cards: Vec, /// Cards the page leaves out (#293): the oldest of a long column, or /// those a filter does not match; `total` is how many it holds. pub hidden: usize, pub total: usize, /// Its archived cards (#293), shown on the archive view. pub archived: usize, /// Its cards are all finished work (a Done column): its note says /// they are archived after a fortnight unless it says otherwise. pub finished: bool, /// More cards than its work-in-progress limit (#361). pub over_wip: bool, /// Done cards that reached it before the open iteration began, left /// out: the board shows this iteration's done work (#361). pub before_iteration: usize, } /// A column longer than this shows its newest cards only, with "show all" /// (#293): a board's Done column otherwise grows without end. pub const COLUMN_SHOWN: usize = 30; /// How the board page shows its cards (#293). #[derive(Deserialize, Default, Clone)] pub struct BoardQuery { /// The column shown whole. #[serde(default)] all: Option, /// Only cards whose title or reference holds these words, and with /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes. #[serde(default)] q: Option, /// Only cards in this state (`open`, `closed`, `landed`…). #[serde(default)] state: Option, /// The archived cards instead (`?archived=1`). #[serde(default)] archived: Option, /// A past iteration: the cards archived under it, and its burn-up (#343). #[serde(default)] iteration: Option, /// A saved view, by name (#361): its layout, filter, lanes and sort; /// what the address says besides wins. #[serde(default)] pub view: Option, /// `board` (the default) or `table`. #[serde(default)] pub layout: Option, /// Swimlanes or groups: `repo`, `assignee`, `label`, `milestone`. #[serde(default)] pub lane: Option, /// The table's order (`title`, `reference`, `state`, `column`, `age`; /// `-` first to reverse). #[serde(default)] pub sort: Option, /// Compact cards: title, reference, avatars, labels as dots. #[serde(default)] pub compact: Option, /// The table's page, from 1. #[serde(default)] pub page: Option, } impl BoardQuery { /// This query over the saved view it names (#361): what the address /// gives wins, the view fills the rest. pub fn over(&self, saved: Option<&pdb::View>) -> BoardQuery { let mut q = self.clone(); if let Some(v) = saved { let fill = |x: &mut Option, from: &str| { if x.is_none() && !from.is_empty() { *x = Some(from.to_string()); } }; fill(&mut q.q, &v.q); fill(&mut q.layout, &v.layout); fill(&mut q.lane, &v.lane); fill(&mut q.sort, &v.sort); } q } pub fn layout(&self) -> &str { match self.layout.as_deref() { Some("table") => "table", _ => "board", } } pub fn lane(&self) -> &str { self.lane.as_deref().filter(|l| matches!(*l, "repo" | "assignee" | "label" | "milestone")).unwrap_or("") } fn compact(&self) -> bool { self.compact.as_deref().is_some_and(|c| !c.is_empty() && c != "0") } fn text(&self) -> String { self.q.as_deref().map(str::trim).unwrap_or("").to_string() } fn state(&self) -> String { self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase() } fn archived(&self) -> bool { self.archived.as_deref().is_some_and(|a| !a.is_empty() && a != "0") } } /// The board filter's `label:`, `assignee:` and `state:` terms, and its words. #[derive(Default)] pub struct CardFilter { labels: Vec, assignees: Vec, state: String, words: Vec, /// `repo:NAME`, `milestone:TITLE` (#361). repos: Vec, milestones: Vec, /// `is:open` (open or draft) or `is:closed` (closed or landed). is: String, /// `age:>14d` and `age:<7d`: opened more than, or less than, so many /// days ago. older_than_days: Option, newer_than_days: Option, } impl CardFilter { fn of(view: &BoardQuery) -> CardFilter { let mut f = CardFilter::parse(&view.text()); if f.state.is_empty() { f.state = view.state(); } f } /// The filter bar's text (#361): words, and `label:`, `assignee:`, /// `state:`, `repo:`, `milestone:`, `is:open|closed` and `age:>Nd` or /// `age: CardFilter { let mut f = CardFilter::default(); for tok in tokens(text) { match tok.split_once(':') { Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()), Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()), Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(), Some(("repo", v)) if !v.is_empty() => f.repos.push(v.rsplit('/').next().unwrap_or(v).to_lowercase()), Some(("milestone", v)) if !v.is_empty() => f.milestones.push(v.to_lowercase()), Some(("is", v)) if matches!(v, "open" | "closed") => f.is = v.to_string(), Some(("age", v)) if age_days(v).is_some() => match age_days(v) { Some((true, d)) => f.older_than_days = Some(d), Some((false, d)) => f.newer_than_days = Some(d), None => {} }, _ => f.words.push(tok.to_lowercase()), } } f } pub fn is_empty(&self) -> bool { self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty() && self.repos.is_empty() && self.milestones.is_empty() && self.is.is_empty() && self.older_than_days.is_none() && self.newer_than_days.is_none() } /// Every term holds: each word in the title or reference, each label /// and assignee among the card's, its state, repository, milestone and /// age. pub fn keeps(&self, c: &CardView, now: i64) -> bool { let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase()); let age_days = (now - c.opened_ms) / DAY_MS; self.words.iter().all(|w| title.contains(w) || reference.contains(w)) && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l))) && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a))) && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state)) && self.repos.iter().all(|r| c.repo.eq_ignore_ascii_case(r)) && self.milestones.iter().all(|m| c.milestone.eq_ignore_ascii_case(m)) && match self.is.as_str() { "open" => matches!(c.state.as_str(), "open" | "draft"), "closed" => matches!(c.state.as_str(), "closed" | "landed"), _ => true, } && self.older_than_days.is_none_or(|d| age_days > d) && self.newer_than_days.is_none_or(|d| age_days < d) } } /// The filter bar's words, a quoted value kept whole (`milestone:"Release 1"`). fn tokens(text: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); let mut quoted = false; for ch in text.chars() { match ch { '"' => quoted = !quoted, c if c.is_whitespace() && !quoted => { if !cur.is_empty() { out.push(std::mem::take(&mut cur)); } } c => cur.push(c), } } if !cur.is_empty() { out.push(cur); } out } /// `>14d` (older than 14 days: `(true, 14)`), `<7d` (newer: `(false, 7)`); /// the `d` may be left off. fn age_days(v: &str) -> Option<(bool, i64)> { let (older, rest) = match v.as_bytes().first()? { b'>' => (true, &v[1..]), b'<' => (false, &v[1..]), _ => return None, }; let n: i64 = rest.trim_end_matches('d').parse().ok()?; (n >= 0).then_some((older, n)) } /// Filter and cut the columns as `view` says. With an iteration open, /// the Done column `done` shows the cards that reached it since the /// iteration began (#361); the rest are counted. fn shape(columns: &mut [ColumnView], view: &BoardQuery, now: i64, done: Option, since: Option) { let filter = CardFilter::of(view); for col in columns.iter_mut() { col.total = col.cards.len(); if let (Some(d), Some(since), false) = (done, since, view.archived() || view.iteration.is_some()) { if col.column.id == d { let before = col.cards.len(); col.cards.retain(|c| c.card.moved_ms >= since); col.before_iteration = before - col.cards.len(); col.total = col.cards.len(); } } col.over_wip = col.column.wip_limit.is_some_and(|w| w >= 0 && col.total as i64 > w); if !filter.is_empty() { col.cards.retain(|c| filter.keeps(c, now)); } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() { // Cards join a column at its end: the newest are the last. let cut = col.cards.len() - COLUMN_SHOWN; col.cards.drain(..cut); } col.hidden = col.total - col.cards.len(); } } /// A swimlane (#361): the board's columns with the cards of one /// repository, assignee, label or milestone. pub struct LaneView { pub name: String, pub columns: Vec, pub count: usize, } /// The lanes a card belongs to under `lane`: one, or one per assignee or /// label; those without one share a lane named for it. fn lanes_of(c: &CardView, lane: &str) -> Vec { let many = |v: Vec, none: &str| if v.is_empty() { vec![none.to_string()] } else { v }; match lane { "repo" => vec![if c.repo.is_empty() { "(notes)".into() } else { c.repo.clone() }], "assignee" => many(c.assignees.iter().map(|a| format!("@{a}")).collect(), "(nobody)"), "label" => many(c.labels.iter().map(|l| l.name.clone()).collect(), "(no label)"), "milestone" => vec![if c.milestone.is_empty() { "(no milestone)".into() } else { c.milestone.clone() }], _ => vec![String::new()], } } /// Split shaped columns into swimlanes by `lane`, named in order with /// the lane of those without one last. pub fn lanes(columns: &[ColumnView], lane: &str) -> Vec { let mut names: Vec = columns.iter().flat_map(|col| col.cards.iter().flat_map(|c| lanes_of(c, lane))).collect(); names.sort_by_key(|n| (n.starts_with('('), n.to_lowercase())); names.dedup(); names .into_iter() .map(|name| { let columns: Vec = columns .iter() .map(|col| { let mut col = col.clone(); col.cards.retain(|c| lanes_of(c, lane).contains(&name)); col.total = col.cards.len(); col.hidden = 0; col.before_iteration = 0; col }) .collect(); let count = columns.iter().map(|c| c.cards.len()).sum(); LaneView { name, columns, count } }) .collect() } /// A card as a table row (#361), with its column and group. pub struct RowView { pub c: CardView, pub column: String, pub column_id: i64, pub group: String, /// The first row of its group, which carries the group's heading. pub starts_group: bool, } /// Rows a table page shows. pub const TABLE_PAGE: usize = 50; /// Every shaped card as a row, grouped by `lane` and ordered by `sort` /// (`title`, `reference`, `state`, `column`, `age`; a leading `-` /// reverses), then the page `page` (from 1) of them and how many pages. pub fn rows(columns: &[ColumnView], lane: &str, sort: &str, page: usize) -> (Vec, usize) { let mut all: Vec = Vec::new(); for (ci, col) in columns.iter().enumerate() { for c in &col.cards { for group in lanes_of(c, lane) { all.push(RowView { c: c.clone(), column: col.column.name.clone(), column_id: ci as i64, group, starts_group: false }); } } } let (desc, key) = match sort.strip_prefix('-') { Some(k) => (true, k), None => (false, sort), }; all.sort_by(|a, b| { let by = match key { "title" => a.c.title.to_lowercase().cmp(&b.c.title.to_lowercase()), "reference" => a.c.reference.cmp(&b.c.reference), "state" => a.c.state.cmp(&b.c.state), "column" => a.column_id.cmp(&b.column_id), "age" => b.c.opened_ms.cmp(&a.c.opened_ms), _ => a.column_id.cmp(&b.column_id).then(a.c.card.position.cmp(&b.c.card.position)), }; let by = if desc { by.reverse() } else { by }; (a.group.starts_with('('), a.group.to_lowercase()).cmp(&(b.group.starts_with('('), b.group.to_lowercase())).then(by) }); let pages = all.len().div_ceil(TABLE_PAGE).max(1); let page = page.clamp(1, pages); let mut shown: Vec = all.into_iter().skip((page - 1) * TABLE_PAGE).take(TABLE_PAGE).collect(); let mut last: Option = None; for r in shown.iter_mut() { r.starts_group = !lane.is_empty() && last.as_ref() != Some(&r.group); last = Some(r.group.clone()); } (shown, pages) } /// An iteration as the board shows it (#343). pub struct IterationView { pub it: pdb::Iteration, /// Its first and last days, YYYY-MM-DD. pub first: String, pub last: String, pub days: i64, /// Its days so far (all of them once it has ended). pub so_far: usize, /// Cards done in it so far. pub done: usize, /// The burn-up as SVG polyline points in a [`BURN_W`] × [`BURN_H`] box, /// one point per day so far; `top` is the count at the top of the box. pub points: String, pub top: usize, } pub const BURN_W: i64 = 280; pub const BURN_H: i64 = 64; /// How many past iterations the board lists. const PAST_SHOWN: usize = 12; const DAY_MS: i64 = 86_400_000; /// `YYYY-MM-DD` to the midnight (UTC) that starts it. fn day_ms(s: &str) -> Option { let mut parts = s.trim().splitn(3, '-').map(|p| p.parse::().ok()); let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??); let month = time::Month::try_from(u8::try_from(m).ok()?).ok()?; let date = time::Date::from_calendar_date(y, month, u8::try_from(d).ok()?).ok()?; Some(date.midnight().assume_utc().unix_timestamp() * 1000) } /// The day (UTC) holding `ms`, as `YYYY-MM-DD`. fn day_of(ms: i64) -> String { let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) } async fn iteration_view(app: &Shared, it: pdb::Iteration, done: Option, now: i64) -> sqlx::Result { let times = pdb::done_times(&app.db, &it, done).await?; let days = pdb::iteration_days(&it); let up = pdb::burn_up(×, it.start_ms, it.end_ms, it.closed_ms.unwrap_or(now)); let top = up.last().copied().unwrap_or(0).max(1); // From nothing at the start to each day's end. let points = std::iter::once((0, 0)) .chain(up.iter().enumerate().map(|(d, &n)| (d as i64 + 1, n))) .map(|(d, n)| format!("{},{}", d * BURN_W / days, BURN_H - n as i64 * BURN_H / top as i64)) .collect::>() .join(" "); Ok(IterationView { first: day_of(it.start_ms), last: day_of(it.end_ms - 1), days, so_far: up.len(), done: times.len(), points, top, it, }) } #[derive(Template)] #[template(path = "project.html")] pub struct BoardPage { shell: Shell, project: pdb::Project, columns: Vec, /// The owner's repositories the viewer can read, for adding cards. repos: Vec, /// Changes cards and columns (an open project). can_edit: bool, /// Changes the settings (to reopen a closed project too). can_manage: bool, notice: Option, error: Option, /// Owners the viewer may move the project to (#125): themselves and the /// organisations they belong to, but not its owner now. transfer_to: Vec, /// The filter the page shows (#293), and whether one is set. q: String, state: String, filtered: bool, /// The archive view: archived cards, each restorable (#293). archived: bool, /// Archived cards on the whole board. archived_total: usize, /// The open iteration, the past ones (newest first), and the one the /// page shows the cards of (#343). current: Option, past: Vec, shown_iteration: Option, /// A new iteration's suggested first and last days: today and 13 days on. next_first: String, next_last: String, /// The saved views as tabs, and the one shown (#361). views: Vec, view_name: String, /// `board` or `table`; lanes or groups; the table's order; compact cards. layout: String, lane: String, sort: String, compact: bool, /// The board in swimlanes, when `lane` is set. lanes: Vec, /// The table's rows, page and pages. rows: Vec, page: usize, pages: usize, /// The address's query for this look, without the page, for links. look: String, } #[allow(clippy::too_many_arguments)] async fn board( app: &Shared, shell: Shell, viewer: &Viewer, owner: &str, number: i64, notice: Option, error: Option, view: &BoardQuery, ) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let r = rights(app, &shell, viewer, owner).await?; let project = pdb::get(&app.db, owner, number) .await .map_err(e)? .filter(|p| p.public || r.member) .ok_or_else(|| Error::not_found(shell.clone()))?; let now = db::now_ms(); let views = pdb::views(&app.db, project.id).await.map_err(e)?; let saved = view.view.as_deref().and_then(|n| views.iter().find(|v| v.name == n)); let look = view.over(saved); let view = &look; let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?; let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned()); // A past iteration's page is the archive of what was done in it. let archived = view.archived() || shown.is_some(); let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?; if let Some(it) = &shown { for col in columns.iter_mut() { col.cards.retain(|c| c.card.iteration_id == Some(it.id)); } } let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::>()); let open_since = iterations.iter().find(|i| i.closed_ms.is_none()).map(|i| i.start_ms); shape(&mut columns, view, now, done, open_since); let archived_total = columns.iter().map(|c| c.archived).sum(); let (layout, lane, sort) = (view.layout().to_string(), view.lane().to_string(), view.sort.clone().unwrap_or_default()); let lanes = if layout == "board" && !lane.is_empty() { lanes(&columns, &lane) } else { Vec::new() }; let (rows, pages) = if layout == "table" { rows(&columns, &lane, &sort, view.page.unwrap_or(1)) } else { (Vec::new(), 1) }; let page = view.page.unwrap_or(1).clamp(1, pages); let mut parts: Vec = Vec::new(); for (k, v) in [ ("view", view.view.clone().unwrap_or_default()), ("layout", if layout == "table" { layout.clone() } else { String::new() }), ("q", view.text()), ("lane", lane.clone()), ("sort", sort.clone()), ("compact", if view.compact() { "1".into() } else { String::new() }), ] { if !v.is_empty() { parts.push(format!("{k}={}", super::settings::urlencode(&v))); } } let look_query = parts.join("&"); let (mut current, mut past) = (None, Vec::new()); for it in iterations { if it.closed_ms.is_none() { current = Some(iteration_view(app, it, done, now).await.map_err(e)?); } else if past.len() < PAST_SHOWN { past.push(iteration_view(app, it, done, now).await.map_err(e)?); } } let shown_iteration = match shown { Some(it) => Some(iteration_view(app, it, done, now).await.map_err(e)?), None => None, }; let can_edit = r.edit && !project.closed; let mut transfer_to = Vec::new(); if let (true, Some(a)) = (r.edit, viewer.account()) { transfer_to.push(a.username.clone()); let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?; transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name)); transfer_to.retain(|o| o != owner); } let (q, state) = (view.text(), view.state()); let filtered = !q.is_empty() || !state.is_empty(); Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to, q, state, filtered, archived, archived_total, current, past, shown_iteration, next_first: day_of(now), next_last: day_of(now + 13 * DAY_MS), view_name: view.view.clone().filter(|n| views.iter().any(|v| &v.name == n)).unwrap_or_default(), views, layout, lane, sort, compact: view.compact(), lanes, rows, page, pages, look: look_query, })) } /// A project's columns with the cards `account` may see, and the owner's /// repositories it can read (for adding cards). `archived`: the archived /// cards instead of those on the board (#293); each column counts its /// archived cards either way. pub async fn board_columns( app: &Shared, account: Option<&db::accounts::Account>, owner: &str, project: &pdb::Project, archived: bool, ) -> sqlx::Result<(Vec, Vec)> { let mut readable: std::collections::HashMap = Default::default(); for row in db::repos::list_by_owner(&app.db, owner).await? { let role = roles::role_for(&app.db, account, &row).await?; if roles::allows(role, Access::Read) { readable.insert(row.id, row); } } let all = pdb::cards(&app.db, project.id).await?; let archived_in = |column: i64| all.iter().filter(|c| c.column_id == column && c.archived_ms.is_some()).count(); let cards: Vec<&pdb::Card> = all.iter().filter(|c| c.archived_ms.is_some() == archived).collect(); // What the cards show, read once per repository, not per card: a // board of 300 cards made 300 queries. let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default(); let mut repo_ids: Vec<(i64, bool)> = cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect(); repo_ids.sort(); repo_ids.dedup(); for (rid, issues) in repo_ids { shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s))); } let mut columns = Vec::new(); for column in pdb::columns(&app.db, project.id).await? { let mut views = Vec::new(); for card in cards.iter().copied().filter(|c| c.column_id == column.id) { match (card.kind.as_str(), card.repo_id, card.number) { ("note", _, _) => views.push(CardView { card: card.clone(), reference: String::new(), url: String::new(), title: card.note.clone(), state: String::new(), labels: Vec::new(), assignees: Vec::new(), repo: String::new(), milestone: String::new(), opened_ms: card.created_ms, }), (kind, Some(rid), Some(n)) => { // Cards of repositories the viewer cannot read are not shown at all. let Some(row) = readable.get(&rid) else { continue }; let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue }; let slug = format!("{}/{}", row.owner, row.name); views.push(CardView { card: card.clone(), reference: format!("{slug}#{n}"), url: format!("/{slug}/{kind}/{n}"), title: s.title.clone(), state: s.state.clone(), labels: s.labels.clone(), assignees: s.assignees.clone(), repo: row.name.clone(), milestone: s.milestone.clone(), opened_ms: s.opened_ms, }); } _ => {} } } let total = views.len(); let archived = archived_in(column.id); let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect(); let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed")); columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished, over_wip: false, before_iteration: 0 }); } let mut repos: Vec = readable.values().map(|r| r.name.clone()).collect(); repos.sort(); Ok((columns, repos)) } /// What a card shows of an issue or a proposal. struct Shown { title: String, state: String, labels: Vec, assignees: Vec, milestone: String, opened_ms: i64, } /// A repository's issues (or proposals) as cards show them, by number. async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result> { use std::collections::HashMap; let mut out = Vec::new(); if issues { let mut labels: HashMap> = HashMap::new(); for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? { labels.entry(id).or_default().push(l); } let mut assignees: HashMap> = HashMap::new(); for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? { assignees.entry(id).or_default().push(u); } let milestones: HashMap = idb::milestones(&app.db, repo_id).await?.into_iter().map(|m| (m.id, m.title)).collect(); for i in idb::list(&app.db, repo_id).await? { let mut a = assignees.remove(&i.id).unwrap_or_default(); a.sort(); let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()).unwrap_or_default(); let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state, milestone, opened_ms: i.created_ms, }; out.push((i.number, s)); } } else { let mut labels: HashMap> = HashMap::new(); for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? { labels.entry(id).or_default().push(l); } for p in db::proposals::list(&app.db, repo_id, &[]).await? { let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state, milestone: String::new(), opened_ms: p.created_ms, }; out.push((p.number, s)); } } Ok(out) } pub async fn show( State(app): State, viewer: Viewer, Path((owner, number)): Path<(String, i64)>, Query(view): Query, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); // A board transferred away (#125): its old address goes to its new one. let e = |e| Error::from_db(shell.clone(), e); if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() { if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? { return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response()); } } Ok(board(&app, shell, &viewer, &owner, number, None, None, &view).await?.into_response()) } #[derive(Deserialize)] pub struct BoardForm { #[serde(rename = "_csrf")] csrf_token: String, action: String, #[serde(default)] card: Option, #[serde(default)] column: Option, #[serde(default)] name: String, /// `repo#12`, or `#12` with `repo`. #[serde(default)] reference: String, #[serde(default)] repo: String, #[serde(default)] note: String, /// Automatic archiving: `finished` (the default), `days` or `never`. #[serde(default)] mode: String, #[serde(default)] title: String, #[serde(default)] description: String, #[serde(default)] private: Option, #[serde(default)] closed: Option, /// The owner to transfer the project to (#125). #[serde(default)] to: String, /// Days, for archiving a column's older cards (#293); empty is never. #[serde(default)] days: String, /// The iteration to close, and a new one's first and last days as /// YYYY-MM-DD (#343). #[serde(default)] iteration: Option, #[serde(default)] start: String, #[serde(default)] end: String, /// A column's work-in-progress limit; empty for none (#361). #[serde(default)] limit: String, /// A view to save (with `name`): its layout, filter, lanes and order; /// or the id of one to delete (#361). #[serde(default)] layout: String, #[serde(default)] q: String, #[serde(default)] lane: String, #[serde(default)] sort: String, #[serde(default)] view: Option, } /// `repo#12` or `#12` (with a repository chosen) to (repository, number). fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> { let r = reference.trim(); let (name, n) = r.split_once('#')?; let name = if name.is_empty() { repo.trim() } else { name.trim() }; let n: i64 = n.trim().parse().ok()?; (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n)) } /// Add a note, or the issue or proposal `reference` names (`repo#12`, or /// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller /// should be told: an unknown repository or number, or a card already there. #[allow(clippy::too_many_arguments)] pub async fn add_card( app: &Shared, account: Option<&db::accounts::Account>, owner: &str, project_id: i64, column_id: i64, reference: &str, repo: &str, note: &str, actor: &str, ) -> sqlx::Result> { if !note.trim().is_empty() { let note: String = note.trim().chars().take(500).collect(); pdb::add_card( &app.db, project_id, pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: ¬e, added_by: actor }, ) .await?; return Ok(Ok(newest_card(app, project_id).await?)); } let Some((repo, n)) = parse_reference(reference, repo) else { return Ok(Err("Name an issue or proposal as repo#12, or write a note.".into())); }; let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) }; let role = roles::role_for(&app.db, account, &row).await?; let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?; let kind = if issues.contains(&n) { "issue" } else if proposals.contains(&n) { "proposal" } else { return Ok(Err(format!("{repo} has no issue or proposal #{n}."))); }; if !roles::allows(role, Access::Read) { return Ok(Err(format!("{repo} has no issue or proposal #{n}."))); } if !pdb::add_card( &app.db, project_id, pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor }, ) .await? { return Ok(Err(format!("{repo}#{n} is already on this project."))); } Ok(Ok(newest_card(app, project_id).await?)) } async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result { sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await } pub async fn act( State(app): State, viewer: Viewer, Path((owner, number)): Path<(String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let r = rights(&app, &shell, &viewer, &owner).await?; let e = |e| Error::from_db(shell.clone(), e); let project = pdb::get(&app.db, &owner, number) .await .map_err(e)? .filter(|p| p.public || r.member) .ok_or_else(|| Error::not_found(shell.clone()))?; if !r.edit { return Err(Error::not_found(shell)); } if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") { return Ok(board( &app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()), &BoardQuery::default(), ) .await? .into_response()); } let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id)); let mut notice = None; let mut error = None; match f.action.as_str() { "add" => { let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) }; if let Err(msg) = add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)? { error = Some(msg); } } "move" => { if let (Some(card), Some(col)) = (f.card, f.column) { pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?; } } "up" | "down" => { if let Some(card) = f.card { pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?; } } "remove" => { if let Some(card) = f.card { pdb::remove_card(&app.db, project.id, card).await.map_err(e)?; } } "archive" => { if let Some(card) = f.card { pdb::archive_cards(&app.db, project.id, &[card]).await.map_err(e)?; } } "restore" => { if let Some(card) = f.card { pdb::restore_card(&app.db, project.id, card).await.map_err(e)?; } return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response()); } "takes_finished" => { let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default(); let on = f.mode == "on"; pdb::set_takes_finished(&app.db, project.id, on.then_some(col)).await.map_err(e)?; notice = Some(if on { format!("Cards move to {name} when their issue closes or their proposal lands or closes.") } else { "Cards stay where they are when their issue closes or their proposal lands.".into() }); } "archive_older" | "archive_after" => { let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; let days = match (f.action.as_str(), f.mode.as_str(), f.days.trim()) { ("archive_after", "finished", _) | ("archive_after", "", "") => None, ("archive_after", "never", _) => Some(pdb::NEVER), (_, _, d) => match d.parse::() { Ok(n) if (0..=3650).contains(&n) => Some(n), _ => { error = Some("Give a number of days, 0 to 3650.".into()); None } }, }; let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default(); if error.is_none() && f.action == "archive_after" { pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?; notice = Some(match days { Some(pdb::NEVER) => format!("{name} keeps its cards until you archive them."), Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }), None => format!("{name} archives finished cards {} days after they reach it.", pdb::FINISHED_KEPT_DAYS), }); } else if let (None, Some(days)) = (&error, days) { let now = db::now_ms(); let ids: Vec = pdb::cards(&app.db, project.id) .await .map_err(e)? .into_iter() .filter(|c| c.column_id == col && c.archived_ms.is_none() && pdb::older_than(c, days, now)) .map(|c| c.id) .collect(); let n = pdb::archive_cards(&app.db, project.id, &ids).await.map_err(e)?; let detail = format!("{n} card(s) of {name} older than {days} days"); db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" })); } } "start_iteration" => { let name: String = f.name.trim().chars().take(60).collect(); match (day_ms(&f.start), day_ms(&f.end)) { _ if name.is_empty() => error = Some("Name the iteration.".into()), (Some(start), Some(last)) if last >= start && last - start < 366 * DAY_MS => { match pdb::start_iteration(&app.db, project.id, &name, start, last + DAY_MS).await.map_err(e)? { Some(_) => notice = Some(format!("{name} started.")), None => error = Some("Close the open iteration before starting another.".into()), } } (Some(_), Some(_)) => error = Some("An iteration ends on or after its first day, within a year.".into()), _ => error = Some("Give the first and last days as dates.".into()), } } "close_iteration" => { let Some(id) = f.iteration else { return Err(Error::bad(shell, "no iteration")) }; match pdb::close_iteration(&app.db, project.id, id, pdb::done_column(&columns)).await.map_err(e)? { Some(n) => { let detail = format!("iteration {id}: {n} done card(s) archived"); db::accounts::audit(&app.db, viewer.account(), "project.iteration_closed", &format!("{owner}/{number}"), &detail) .await .ok(); notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" })); } None => error = Some("That iteration is not open.".into()), } } "wip" => { let limit = f.limit.trim(); match (column, limit.parse::()) { (Some(col), _) if limit.is_empty() => pdb::set_wip_limit(&app.db, project.id, col, None).await.map_err(e)?, (Some(col), Ok(n)) if (0..=999).contains(&n) => pdb::set_wip_limit(&app.db, project.id, col, Some(n)).await.map_err(e)?, _ => error = Some("A work-in-progress limit is a number of cards, 0 to 999, or empty for none.".into()), } } "save_view" => { let name: String = f.name.trim().chars().take(40).collect(); let layout = if f.layout == "table" { "table" } else { "board" }; let lane = if matches!(f.lane.as_str(), "repo" | "assignee" | "label" | "milestone") { f.lane.as_str() } else { "" }; let sort: String = f.sort.trim().chars().take(20).collect(); let q: String = f.q.trim().chars().take(300).collect(); if name.is_empty() { error = Some("Name the view.".into()); } else { pdb::save_view(&app.db, project.id, &name, layout, &q, lane, &sort).await.map_err(e)?; return Ok(Redirect::to(&format!("/projects/{owner}/{number}?view={}", super::settings::urlencode(&name))).into_response()); } } "delete_view" => { if let Some(id) = f.view { pdb::delete_view(&app.db, project.id, id).await.map_err(e)?; } } "add_column" => { let name = f.name.trim(); if name.is_empty() || name.len() > 40 { error = Some("A column has a name of up to 40 characters.".into()); } else { pdb::add_column(&app.db, project.id, name).await.map_err(e)?; } } "rename_column" => { if let (Some(col), name) = (f.column, f.name.trim()) { if !name.is_empty() && name.len() <= 40 { pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?; } } } "left" | "right" => { if let Some(col) = f.column { pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?; } } "remove_column" => { if let Some(col) = f.column { if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? { error = Some("A project keeps at least one column.".into()); } } } "settings" => { let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() }; pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?; notice = Some("Saved.".into()); } "transfer" => { let to = f.to.trim(); match rights_of(&app, viewer.account(), to).await.map_err(e)? { _ if to == owner => error = Some(format!("It is {owner}'s already.")), Some(t) if t.edit => { let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?; let detail = format!("{owner}/{number} to {to}/{}", moved.number); db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok(); return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response()); } _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")), } } "delete" => { pdb::delete(&app.db, project.id).await.map_err(e)?; db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok(); return Ok(Redirect::to(&format!("/projects/{owner}")).into_response()); } _ => return Err(Error::bad(shell, "unknown action")), } if notice.is_none() && error.is_none() { // Back to the board, so a reload does not repeat the action. return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response()); } Ok(board(&app, shell, &viewer, &owner, number, notice, error, &BoardQuery::default()).await?.into_response()) } /// The projects an issue or proposal is on that the viewer may see, with /// their column. pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> { let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] }; let mut out = Vec::new(); for (p, col) in all { let visible = p.public || viewer.is_admin() || match viewer.account() { Some(v) if v.username == p.owner => true, Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() { Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(), None => false, }, None => false, }; if visible { out.push((p, col)); } } out } #[cfg(test)] mod tests { use super::parse_reference; #[test] fn references() { assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12))); assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7))); assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3))); assert_eq!(parse_reference("#7", ""), None, "a repository is needed"); assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories"); assert_eq!(parse_reference("hub#0", ""), None); assert_eq!(parse_reference("hub", ""), None); } } #[derive(Deserialize)] pub struct PlaceForm { #[serde(rename = "_csrf")] csrf_token: String, /// The project's number among its owner's. project: i64, /// `add` or `remove`. action: String, } /// Put an issue or proposal on one of its repository owner's projects (in /// the first column), or take it off, from its own page. async fn place( app: Shared, viewer: Viewer, owner: String, name: String, kind: &'static str, number: i64, f: PlaceForm, ) -> Result { let page = format!("/{owner}/{name}/{kind}/{number}"); let shell = Shell::new(&app, &page, &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?; let r = rights(&app, &shell, &viewer, &row.owner).await?; if !r.edit { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); let mut error = None; match f.action.as_str() { "add" => { let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) }; if let Err(msg) = add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor) .await .map_err(e)? { error = Some(msg); } } "remove" => { for c in pdb::cards(&app.db, project.id).await.map_err(e)? { if c.repo_id == Some(row.id) && c.number == Some(number) { pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?; } } } _ => return Err(Error::bad(shell, "add or remove")), } let to = match error { Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)), None => page, }; Ok(Redirect::to(&to).into_response()) } /// `POST /{owner}/{name}/proposal/{number}/project`. pub async fn place_proposal( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { place(app, viewer, owner, name, "proposal", number, f).await } /// `POST /{owner}/{name}/issue/{number}/project`. pub async fn place_issue( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { place(app, viewer, owner, name, "issue", number, f).await } /// The owner's open projects `viewer` may add an item to that it is not on /// yet; `None` when the viewer may not change the owner's projects at all. pub async fn project_choices( app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, placed: &[(pdb::Project, String)], ) -> Option> { let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None }; if !r.edit { return None; } Some( pdb::of_owner(&app.db, &repo.owner) .await .unwrap_or_default() .into_iter() .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id)) .collect(), ) } #[cfg(test)] mod view_tests { use super::*; const NOW: i64 = 100 * DAY_MS; #[allow(clippy::too_many_arguments)] fn card( id: i64, title: &str, repo: &str, state: &str, assignees: &[&str], labels: &[&str], milestone: &str, age_days: i64, ) -> CardView { CardView { card: pdb::Card { id, project_id: 1, column_id: 1, position: id, kind: "issue".into(), repo_id: Some(1), number: Some(id), note: String::new(), added_by: String::new(), created_ms: 0, archived_ms: None, moved_ms: NOW - age_days * DAY_MS, iteration_id: None, }, reference: format!("andy/{repo}#{id}"), url: format!("/andy/{repo}/issue/{id}"), title: title.into(), state: state.into(), labels: labels .iter() .map(|l| idb::Label { id: 0, repo_id: 1, name: (*l).into(), colour: "#123456".into(), description: String::new() }) .collect(), assignees: assignees.iter().map(|a| (*a).to_string()).collect(), repo: repo.into(), milestone: milestone.into(), opened_ms: NOW - age_days * DAY_MS, } } fn column(id: i64, name: &str, wip: Option, cards: Vec) -> ColumnView { ColumnView { column: pdb::Column { id, project_id: 1, name: name.into(), position: id, archive_after_days: None, takes_finished: name == "Done", wip_limit: wip, }, total: cards.len(), cards, hidden: 0, archived: 0, finished: false, over_wip: false, before_iteration: 0, } } fn board() -> Vec { vec![ column( 1, "Doing", Some(1), vec![ card(1, "Crash on start", "oxy", "open", &["andy"], &["bug"], "Release 1", 20), card(2, "Faster pull", "hub", "open", &[], &["perf", "bug"], "", 3), ], ), column(2, "Done", None, vec![card(3, "Docs", "oxy", "closed", &["kim"], &[], "Release 1", 40)]), ] } fn keeps(q: &str) -> Vec { let f = CardFilter::parse(q); board().iter().flat_map(|c| c.cards.iter()).filter(|c| f.keeps(c, NOW)).map(|c| c.card.id).collect() } #[test] fn the_filter_bar_reads_every_term() { assert_eq!(keeps(""), [1, 2, 3]); assert_eq!(keeps("repo:oxy"), [1, 3]); assert_eq!(keeps("repo:andy/hub"), [2]); assert_eq!(keeps("assignee:@andy"), [1]); assert_eq!(keeps("label:bug label:perf"), [2]); assert_eq!(keeps(r#"milestone:"release 1""#), [1, 3]); assert_eq!(keeps("is:open"), [1, 2]); assert_eq!(keeps("is:closed"), [3]); assert_eq!(keeps("age:>14d"), [1, 3]); assert_eq!(keeps("age:<7"), [2]); assert_eq!(keeps("crash age:>14d"), [1]); assert_eq!(keeps("age:soon"), Vec::::new(), "an unreadable term is a word, which matches nothing"); assert!(CardFilter::parse(" ").is_empty()); } #[test] fn wip_limits_and_this_iterations_done() { let mut cols = board(); cols[1].cards.push(card(4, "Old", "oxy", "closed", &[], &[], "", 1)); cols[1].cards[1].card.moved_ms = NOW - 30 * DAY_MS; let q = BoardQuery::default(); shape(&mut cols, &q, NOW, Some(2), Some(NOW - 50 * DAY_MS + 1)); assert!(cols[0].over_wip, "two cards over a limit of one"); assert!(!cols[1].over_wip); // An iteration that began 50 days ago: card 3 (in Done for 40 days) // and card 4 (30) both reached Done during it. assert_eq!((cols[1].cards.len(), cols[1].before_iteration), (2, 0)); let mut cols = board(); shape(&mut cols, &q, NOW, Some(2), Some(NOW - 10 * DAY_MS)); assert_eq!((cols[1].cards.len(), cols[1].before_iteration, cols[1].total), (0, 1, 0), "done before it began"); } #[test] fn lanes_and_table_rows() { let cols = board(); let names: Vec<(String, usize)> = lanes(&cols, "label").into_iter().map(|l| (l.name, l.count)).collect(); assert_eq!(names, [("bug".to_string(), 2), ("perf".into(), 1), ("(no label)".into(), 1)]); let by_repo: Vec = lanes(&cols, "repo").into_iter().map(|l| l.name).collect(); assert_eq!(by_repo, ["hub", "oxy"]); let (shown, pages) = super::rows(&cols, "", "title", 1); assert_eq!(pages, 1); assert_eq!(shown.iter().map(|r| r.c.card.id).collect::>(), [1, 3, 2]); let (shown, _) = super::rows(&cols, "", "-age", 1); assert_eq!(shown.iter().map(|r| r.c.card.id).collect::>(), [3, 1, 2], "oldest first"); let (shown, _) = super::rows(&cols, "assignee", "", 1); let groups: Vec<(&str, bool)> = shown.iter().map(|r| (r.group.as_str(), r.starts_group)).collect(); assert_eq!(groups, [("@andy", true), ("@kim", true), ("(nobody)", true)]); // Paged: 120 cards are three pages of fifty. let many = vec![column(1, "Doing", None, (0..120).map(|i| card(i, "x", "oxy", "open", &[], &[], "", 1)).collect())]; let (p3, pages) = super::rows(&many, "", "", 3); assert_eq!((p3.len(), pages), (20, 3)); } }