//! 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. 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, } 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, } /// 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)] 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, } impl BoardQuery { 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)] struct CardFilter { labels: Vec, assignees: Vec, state: String, words: Vec, } impl CardFilter { fn of(view: &BoardQuery) -> CardFilter { let mut f = CardFilter { state: view.state(), ..Default::default() }; for tok in view.text().split_whitespace() { 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(), _ => f.words.push(tok.to_lowercase()), } } f } fn is_empty(&self) -> bool { self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty() } /// Every term holds: each word in the title or reference, each label /// and assignee among the card's, and its state. fn keeps(&self, c: &CardView) -> bool { let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase()); 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)) } } /// Filter and cut the columns as `view` says. fn shape(columns: &mut [ColumnView], view: &BoardQuery) { let filter = CardFilter::of(view); for col in columns.iter_mut() { col.total = col.cards.len(); if !filter.is_empty() { col.cards.retain(|c| filter.keeps(c)); } 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(); } } /// 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, } #[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 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)); } } shape(&mut columns, view); let archived_total = columns.iter().map(|c| c.archived).sum(); let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::>()); 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), })) } /// 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(), }), (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(), }); } _ => {} } } 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 }); } 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, } /// 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); } for i in idb::list(&app.db, repo_id).await? { let mut a = assignees.remove(&i.id).unwrap_or_default(); a.sort(); let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state }; 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 }; 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, } /// `repo#12` or `#12` (with a repository chosen) to (repository, number). /// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the /// board's (the form the board itself shows, oxygenhub#118). `Err` says /// what is wrong. fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> { let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string(); let r = reference.trim(); let (name, n) = r.split_once('#').ok_or_else(bad)?; let name = if name.is_empty() { repo.trim() } else { name.trim() }; let name = match name.split_once('/') { Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest, Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")), None => name, }; let n: i64 = n.trim().parse().map_err(|_| bad())?; if name.is_empty() || n <= 0 || name.contains('/') { return Err(bad()); } Ok((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 (repo, n) = match parse_reference(reference, repo, owner) { Ok(r) => r, Err(why) => return Ok(Err(why)), }; 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()), } } "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() { let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok(); assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12))); assert_eq!(p("#7", "hub"), Some(("hub".into(), 7))); assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3))); assert_eq!(p("#7", ""), None, "a repository is needed"); assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards"); assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why"); assert_eq!(p("hub#0", ""), None); assert_eq!(p("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(), ) }