OXYGENHUB

Coverage / crates/hub/src/web/projects.rs 92.7%

1053 of 1136 lines run, 170 of 298 functions · run #544 on main · run missed

1
//! Projects (hub spec §8.2): boards of issues, proposals and notes across
2
//! the repositories of one person or organisation.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{roles, Access, Viewer};
7
use crate::db::{self, issues as idb, projects as pdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
14
/// What a caller may do with an owner's projects.
15
pub struct Rights {
16
    /// Sees private projects.
17
    pub member: bool,
18
    /// Creates and changes projects.
19
    pub edit: bool,
20
}
21
22
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23
/// `None` when there is no such person or organisation.
24382×
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25382×
    let admin = account.is_some_and(|a| a.is_admin);
26382×
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27366×
        let me = account.is_some_and(|v| v.id == a.id);
28366×
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
2916×
    }
3016×
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
3114×
        let member = match account {
3214×
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
330×
            None => false,
34
        };
3514×
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
362×
    }
372×
    Ok(None)
38382×
}
39
40125×
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41125×
    rights_of(app, viewer.account(), owner)
42125×
        .await
43125×
        .map_err(|e| Error::from_db(shell.clone(), e))?
44125×
        .ok_or_else(|| Error::not_found(shell.clone()))
45125×
}
46
47
#[derive(Template)]
48
#[template(path = "projects.html")]
49
pub struct ProjectsPage {
50
    shell: Shell,
51
    owner: String,
52
    /// The projects shown: open or closed as `closed` says, matching `q`.
53
    projects: Vec<pdb::Project>,
54
    can_edit: bool,
55
    error: Option<String>,
56
    /// On your own page, the organisations you belong to: their boards
57
    /// are one click away, and where a board for one is made (#119).
58
    orgs: Vec<String>,
59
    /// The title filter, and whether the closed ones are shown (#124).
60
    q: String,
61
    closed: bool,
62
    open_count: usize,
63
    closed_count: usize,
64
}
65
66
impl ProjectsPage {
67
    /// The page for `all` the projects the viewer may see: counted open
68
    /// and closed among those matching `q`, then the ones asked for.
69
    #[allow(clippy::too_many_arguments)]
7013×
    fn build(
7113×
        shell: Shell,
7213×
        owner: String,
7313×
        all: Vec<pdb::Project>,
7413×
        can_edit: bool,
7513×
        error: Option<String>,
7613×
        orgs: Vec<String>,
7713×
        q: Option<String>,
7813×
        closed: bool,
7913×
    ) -> ProjectsPage {
8013×
        let q = q.unwrap_or_default().trim().to_string();
8113×
        let needle = q.to_lowercase();
8213×
        let matching: Vec<pdb::Project> = all
8313×
            .into_iter()
8413×
            .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle))
8513×
            .collect();
8613×
        let closed_count = matching.iter().filter(|p| p.closed).count();
8713×
        let open_count = matching.len() - closed_count;
8813×
        let projects = matching.into_iter().filter(|p| p.closed == closed).collect();
8913×
        ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count }
9013×
    }
91
}
92
93
/// `?q=TEXT&state=open|closed` on the Projects page (#124).
94
#[derive(Deserialize, Default)]
95
pub struct ProjectsQuery {
96
    #[serde(default)]
97
    q: Option<String>,
98
    #[serde(default)]
99
    state: Option<String>,
100
}
101
102
/// The organisations the viewer belongs to, when `owner` is the viewer.
10313×
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
10413×
    match viewer.account() {
10512×
        Some(a) if a.username == owner => {
10611×
            crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default()
107
        }
1082×
        _ => vec![],
109
    }
11013×
}
111
11214×
pub async fn list(
11314×
    State(app): State<Shared>,
11414×
    viewer: Viewer,
11514×
    Path(owner): Path<String>,
11614×
    Query(query): Query<ProjectsQuery>,
11714×
) -> Result<Page<ProjectsPage>, Error> {
11814×
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
11914×
    let r = rights(&app, &shell, &viewer, &owner).await?;
12013×
    let projects = pdb::of_owner(&app.db, &owner)
12113×
        .await
12213×
        .map_err(|e| Error::from_db(shell.clone(), e))?
12313×
        .into_iter()
12413×
        .filter(|p| p.public || r.member)
12513×
        .collect();
12613×
    let orgs = own_orgs(&app, &viewer, &owner).await;
12713×
    let closed = query.state.as_deref() == Some("closed");
12813×
    Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed)))
12914×
}
130
131
#[derive(Deserialize)]
132
pub struct NewProjectForm {
133
    #[serde(rename = "_csrf")]
134
    csrf_token: String,
135
    title: String,
136
    #[serde(default)]
137
    description: String,
138
    #[serde(default)]
139
    private: Option<String>,
140
}
141
1428×
pub async fn create(
1438×
    State(app): State<Shared>,
1448×
    viewer: Viewer,
1458×
    Path(owner): Path<String>,
1468×
    Form(f): Form<NewProjectForm>,
1478×
) -> Result<Response, Error> {
1488×
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
1498×
    csrf(&viewer, &f.csrf_token, &shell)?;
1508×
    let r = rights(&app, &shell, &viewer, &owner).await?;
1518×
    if !r.edit {
1520×
        return Err(Error::not_found(shell));
1538×
    }
1548×
    let title = f.title.trim();
1558×
    if title.is_empty() || title.len() > 100 {
1560×
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1570×
        let orgs = own_orgs(&app, &viewer, &owner).await;
1580×
        return Ok(Page(ProjectsPage::build(
1590×
            shell,
1600×
            owner,
1610×
            projects,
1620×
            true,
1630×
            Some("A project needs a title of up to 100 characters.".into()),
1640×
            orgs,
1650×
            None,
1660×
            false,
1670×
        ))
1680×
        .into_response());
1698×
    }
1708×
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
1718×
        .await
1728×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1738×
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
1748×
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
1758×
}
176
177
/// A card as the board shows it.
178
#[derive(Clone)]
179
pub struct CardView {
180
    pub card: pdb::Card,
181
    /// `owner/name#N`, for issue and proposal cards.
182
    pub reference: String,
183
    pub url: String,
184
    pub title: String,
185
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
186
    pub state: String,
187
    /// The issue's or proposal's labels, and an issue's assignees (#293).
188
    pub labels: Vec<idb::Label>,
189
    pub assignees: Vec<String>,
190
    /// Its repository's name, its milestone's title, and when the issue or
191
    /// proposal was opened (a note: when it was added), for the filter's
192
    /// `repo:`, `milestone:` and `age:` and for lanes (#361).
193
    pub repo: String,
194
    pub milestone: String,
195
    pub opened_ms: i64,
196
}
197
198
#[derive(Clone)]
199
pub struct ColumnView {
200
    pub column: pdb::Column,
201
    pub cards: Vec<CardView>,
202
    /// Cards the page leaves out (#293): the oldest of a long column, or
203
    /// those a filter does not match; `total` is how many it holds.
204
    pub hidden: usize,
205
    pub total: usize,
206
    /// Its archived cards (#293), shown on the archive view.
207
    pub archived: usize,
208
    /// Its cards are all finished work (a Done column): its note says
209
    /// they are archived after a fortnight unless it says otherwise.
210
    pub finished: bool,
211
    /// More cards than its work-in-progress limit (#361).
212
    pub over_wip: bool,
213
    /// Done cards that reached it before the open iteration began, left
214
    /// out: the board shows this iteration's done work (#361).
215
    pub before_iteration: usize,
216
}
217
218
/// A column longer than this shows its newest cards only, with "show all"
219
/// (#293): a board's Done column otherwise grows without end.
220
pub const COLUMN_SHOWN: usize = 30;
221
222
/// How the board page shows its cards (#293).
223
#[derive(Deserialize, Default, Clone)]
224
pub struct BoardQuery {
225
    /// The column shown whole.
226
    #[serde(default)]
227
    all: Option<i64>,
228
    /// Only cards whose title or reference holds these words, and with
229
    /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes.
230
    #[serde(default)]
231
    q: Option<String>,
232
    /// Only cards in this state (`open`, `closed`, `landed`…).
233
    #[serde(default)]
234
    state: Option<String>,
235
    /// The archived cards instead (`?archived=1`).
236
    #[serde(default)]
237
    archived: Option<String>,
238
    /// A past iteration: the cards archived under it, and its burn-up (#343).
239
    #[serde(default)]
240
    iteration: Option<i64>,
241
    /// A saved view, by name (#361): its layout, filter, lanes and sort;
242
    /// what the address says besides wins.
243
    #[serde(default)]
244
    pub view: Option<String>,
245
    /// `board` (the default) or `table`.
246
    #[serde(default)]
247
    pub layout: Option<String>,
248
    /// Swimlanes or groups: `repo`, `assignee`, `label`, `milestone`.
249
    #[serde(default)]
250
    pub lane: Option<String>,
251
    /// The table's order (`title`, `reference`, `state`, `column`, `age`;
252
    /// `-` first to reverse).
253
    #[serde(default)]
254
    pub sort: Option<String>,
255
    /// Compact cards: title, reference, avatars, labels as dots.
256
    #[serde(default)]
257
    pub compact: Option<String>,
258
    /// The table's page, from 1.
259
    #[serde(default)]
260
    pub page: Option<usize>,
261
}
262
263
impl BoardQuery {
264
    /// This query over the saved view it names (#361): what the address
265
    /// gives wins, the view fills the rest.
26657×
    pub fn over(&self, saved: Option<&pdb::View>) -> BoardQuery {
26757×
        let mut q = self.clone();
26857×
        if let Some(v) = saved {
2694×
            let fill = |x: &mut Option<String>, from: &str| {
2704×
                if x.is_none() && !from.is_empty() {
2713×
                    *x = Some(from.to_string());
2723×
                }
2734×
            };
2741×
            fill(&mut q.q, &v.q);
2751×
            fill(&mut q.layout, &v.layout);
2761×
            fill(&mut q.lane, &v.lane);
2771×
            fill(&mut q.sort, &v.sort);
27856×
        }
27957×
        q
28057×
    }
28157×
    pub fn layout(&self) -> &str {
28257×
        match self.layout.as_deref() {
2831×
            Some("table") => "table",
28456×
            _ => "board",
285
        }
28657×
    }
28757×
    pub fn lane(&self) -> &str {
28857×
        self.lane.as_deref().filter(|l| matches!(*l, "repo" | "assignee" | "label" | "milestone")).unwrap_or("")
28957×
    }
290114×
    fn compact(&self) -> bool {
291114×
        self.compact.as_deref().is_some_and(|c| !c.is_empty() && c != "0")
292114×
    }
293173×
    fn text(&self) -> String {
294173×
        self.q.as_deref().map(str::trim).unwrap_or("").to_string()
295173×
    }
296115×
    fn state(&self) -> String {
297115×
        self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase()
298115×
    }
299227×
    fn archived(&self) -> bool {
300227×
        self.archived.as_deref().is_some_and(|a| !a.is_empty() && a != "0")
301227×
    }
302
}
303
304
/// The board filter's `label:`, `assignee:` and `state:` terms, and its words.
305
#[derive(Default)]
306
pub struct CardFilter {
307
    labels: Vec<String>,
308
    assignees: Vec<String>,
309
    state: String,
310
    words: Vec<String>,
311
    /// `repo:NAME`, `milestone:TITLE` (#361).
312
    repos: Vec<String>,
313
    milestones: Vec<String>,
314
    /// `is:open` (open or draft) or `is:closed` (closed or landed).
315
    is: String,
316
    /// `age:>14d` and `age:<7d`: opened more than, or less than, so many
317
    /// days ago.
318
    older_than_days: Option<i64>,
319
    newer_than_days: Option<i64>,
320
}
321
322
impl CardFilter {
32359×
    fn of(view: &BoardQuery) -> CardFilter {
32459×
        let mut f = CardFilter::parse(&view.text());
32559×
        if f.state.is_empty() {
32658×
            f.state = view.state();
32758×
        }
32859×
        f
32959×
    }
330
331
    /// The filter bar's text (#361): words, and `label:`, `assignee:`,
332
    /// `state:`, `repo:`, `milestone:`, `is:open|closed` and `age:>Nd` or
333
    /// `age:<Nd`. A value with spaces takes quotes: `milestone:"Release 1"`.
33476×
    pub fn parse(text: &str) -> CardFilter {
33576×
        let mut f = CardFilter::default();
33676×
        for tok in tokens(text) {
33725×
            match tok.split_once(':') {
33821×
                Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()),
33914×
                Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()),
34012×
                Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(),
34111×
                Some(("repo", v)) if !v.is_empty() => f.repos.push(v.rsplit('/').next().unwrap_or(v).to_lowercase()),
3428×
                Some(("milestone", v)) if !v.is_empty() => f.milestones.push(v.to_lowercase()),
3437×
                Some(("is", v)) if matches!(v, "open" | "closed") => f.is = v.to_string(),
3444×
                Some(("age", v)) if age_days(v).is_some() => match age_days(v) {
3452×
                    Some((true, d)) => f.older_than_days = Some(d),
3461×
                    Some((false, d)) => f.newer_than_days = Some(d),
3470×
                    None => {}
348
                },
3495×
                _ => f.words.push(tok.to_lowercase()),
350
            }
351
        }
35276×
        f
35376×
    }
354
355170×
    pub fn is_empty(&self) -> bool {
356170×
        self.labels.is_empty()
357161×
            && self.assignees.is_empty()
358158×
            && self.state.is_empty()
359152×
            && self.words.is_empty()
360146×
            && self.repos.is_empty()
361143×
            && self.milestones.is_empty()
362143×
            && self.is.is_empty()
363140×
            && self.older_than_days.is_none()
364140×
            && self.newer_than_days.is_none()
365170×
    }
366
367
    /// Every term holds: each word in the title or reference, each label
368
    /// and assignee among the card's, its state, repository, milestone and
369
    /// age.
370129×
    pub fn keeps(&self, c: &CardView, now: i64) -> bool {
371129×
        let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase());
372129×
        let age_days = (now - c.opened_ms) / DAY_MS;
373129×
        self.words.iter().all(|w| title.contains(w) || reference.contains(w))
37488×
            && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l)))
37582×
            && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a)))
37678×
            && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state))
37740×
            && self.repos.iter().all(|r| c.repo.eq_ignore_ascii_case(r))
37835×
            && self.milestones.iter().all(|m| c.milestone.eq_ignore_ascii_case(m))
37934×
            && match self.is.as_str() {
38034×
                "open" => matches!(c.state.as_str(), "open" | "draft"),
38128×
                "closed" => matches!(c.state.as_str(), "closed" | "landed"),
38225×
                _ => true,
383
            }
38430×
            && self.older_than_days.is_none_or(|d| age_days > d)
38529×
            && self.newer_than_days.is_none_or(|d| age_days < d)
386129×
    }
387
}
388
389
/// The filter bar's words, a quoted value kept whole (`milestone:"Release 1"`).
39076×
fn tokens(text: &str) -> Vec<String> {
39176×
    let mut out = Vec::new();
39276×
    let mut cur = String::new();
39376×
    let mut quoted = false;
394243×
    for ch in text.chars() {
395241×
        match ch {
3962×
            '"' => quoted = !quoted,
397241×
            c if c.is_whitespace() && !quoted => {
3986×
                if !cur.is_empty() {
3994×
                    out.push(std::mem::take(&mut cur));
4004×
                }
401
            }
402235×
            c => cur.push(c),
403
        }
404
    }
40576×
    if !cur.is_empty() {
40621×
        out.push(cur);
40755×
    }
40876×
    out
40976×
}
410
411
/// `>14d` (older than 14 days: `(true, 14)`), `<7d` (newer: `(false, 7)`);
412
/// the `d` may be left off.
4137×
fn age_days(v: &str) -> Option<(bool, i64)> {
4147×
    let (older, rest) = match v.as_bytes().first()? {
4154×
        b'>' => (true, &v[1..]),
4162×
        b'<' => (false, &v[1..]),
4171×
        _ => return None,
418
    };
4196×
    let n: i64 = rest.trim_end_matches('d').parse().ok()?;
4206×
    (n >= 0).then_some((older, n))
4217×
}
422
423
/// Filter and cut the columns as `view` says. With an iteration open,
424
/// the Done column `done` shows the cards that reached it since the
425
/// iteration began (#361); the rest are counted.
42659×
fn shape(columns: &mut [ColumnView], view: &BoardQuery, now: i64, done: Option<i64>, since: Option<i64>) {
42759×
    let filter = CardFilter::of(view);
428169×
    for col in columns.iter_mut() {
429169×
        col.total = col.cards.len();
430169×
        if let (Some(d), Some(since), false) = (done, since, view.archived() || view.iteration.is_some()) {
43113×
            if col.column.id == d {
4325×
                let before = col.cards.len();
4339×
                col.cards.retain(|c| c.card.moved_ms >= since);
4345×
                col.before_iteration = before - col.cards.len();
4355×
                col.total = col.cards.len();
4368×
            }
437156×
        }
438169×
        col.over_wip = col.column.wip_limit.is_some_and(|w| w >= 0 && col.total as i64 > w);
439169×
        if !filter.is_empty() {
44092×
            col.cards.retain(|c| filter.keeps(c, now));
441139×
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() {
4421×
            // Cards join a column at its end: the newest are the last.
4431×
            let cut = col.cards.len() - COLUMN_SHOWN;
4441×
            col.cards.drain(..cut);
445138×
        }
446169×
        col.hidden = col.total - col.cards.len();
447
    }
44859×
}
449
450
/// A swimlane (#361): the board's columns with the cards of one
451
/// repository, assignee, label or milestone.
452
pub struct LaneView {
453
    pub name: String,
454
    pub columns: Vec<ColumnView>,
455
    pub count: usize,
456
}
457
458
/// The lanes a card belongs to under `lane`: one, or one per assignee or
459
/// label; those without one share a lane named for it.
460164×
fn lanes_of(c: &CardView, lane: &str) -> Vec<String> {
461164×
    let many = |v: Vec<String>, none: &str| if v.is_empty() { vec![none.to_string()] } else { v };
462164×
    match lane {
463164×
        "repo" => vec![if c.repo.is_empty() { "(notes)".into() } else { c.repo.clone() }],
464141×
        "assignee" => many(c.assignees.iter().map(|a| format!("@{a}")).collect(), "(nobody)"),
465138×
        "label" => many(c.labels.iter().map(|l| l.name.clone()).collect(), "(no label)"),
466126×
        "milestone" => vec![if c.milestone.is_empty() { "(no milestone)".into() } else { c.milestone.clone() }],
467126×
        _ => vec![String::new()],
468
    }
469164×
}
470
471
/// Split shaped columns into swimlanes by `lane`, named in order with
472
/// the lane of those without one last.
4733×
pub fn lanes(columns: &[ColumnView], lane: &str) -> Vec<LaneView> {
4749×
    let mut names: Vec<String> = columns.iter().flat_map(|col| col.cards.iter().flat_map(|c| lanes_of(c, lane))).collect();
47518×
    names.sort_by_key(|n| (n.starts_with('('), n.to_lowercase()));
4763×
    names.dedup();
4773×
    names
4783×
        .into_iter()
4798×
        .map(|name| {
4808×
            let columns: Vec<ColumnView> = columns
4818×
                .iter()
48219×
                .map(|col| {
48319×
                    let mut col = col.clone();
48424×
                    col.cards.retain(|c| lanes_of(c, lane).contains(&name));
48519×
                    col.total = col.cards.len();
48619×
                    col.hidden = 0;
48719×
                    col.before_iteration = 0;
48819×
                    col
48919×
                })
4908×
                .collect();
49119×
            let count = columns.iter().map(|c| c.cards.len()).sum();
4928×
            LaneView { name, columns, count }
4938×
        })
4943×
        .collect()
4953×
}
496
497
/// A card as a table row (#361), with its column and group.
498
pub struct RowView {
499
    pub c: CardView,
500
    pub column: String,
501
    pub column_id: i64,
502
    pub group: String,
503
    /// The first row of its group, which carries the group's heading.
504
    pub starts_group: bool,
505
}
506
507
/// Rows a table page shows.
508
pub const TABLE_PAGE: usize = 50;
509
510
/// Every shaped card as a row, grouped by `lane` and ordered by `sort`
511
/// (`title`, `reference`, `state`, `column`, `age`; a leading `-`
512
/// reverses), then the page `page` (from 1) of them and how many pages.
5135×
pub fn rows(columns: &[ColumnView], lane: &str, sort: &str, page: usize) -> (Vec<RowView>, usize) {
5145×
    let mut all: Vec<RowView> = Vec::new();
51510×
    for (ci, col) in columns.iter().enumerate() {
516131×
        for c in &col.cards {
517131×
            for group in lanes_of(c, lane) {
518131×
                all.push(RowView { c: c.clone(), column: col.column.name.clone(), column_id: ci as i64, group, starts_group: false });
519131×
            }
520
        }
521
    }
5225×
    let (desc, key) = match sort.strip_prefix('-') {
5231×
        Some(k) => (true, k),
5244×
        None => (false, sort),
525
    };
526129×
    all.sort_by(|a, b| {
527129×
        let by = match key {
528129×
            "title" => a.c.title.to_lowercase().cmp(&b.c.title.to_lowercase()),
529126×
            "reference" => a.c.reference.cmp(&b.c.reference),
530126×
            "state" => a.c.state.cmp(&b.c.state),
531126×
            "column" => a.column_id.cmp(&b.column_id),
532126×
            "age" => b.c.opened_ms.cmp(&a.c.opened_ms),
533123×
            _ => a.column_id.cmp(&b.column_id).then(a.c.card.position.cmp(&b.c.card.position)),
534
        };
535129×
        let by = if desc { by.reverse() } else { by };
536129×
        (a.group.starts_with('('), a.group.to_lowercase()).cmp(&(b.group.starts_with('('), b.group.to_lowercase())).then(by)
537129×
    });
5385×
    let pages = all.len().div_ceil(TABLE_PAGE).max(1);
5395×
    let page = page.clamp(1, pages);
5405×
    let mut shown: Vec<RowView> = all.into_iter().skip((page - 1) * TABLE_PAGE).take(TABLE_PAGE).collect();
5415×
    let mut last: Option<String> = None;
54231×
    for r in shown.iter_mut() {
54331×
        r.starts_group = !lane.is_empty() && last.as_ref() != Some(&r.group);
54431×
        last = Some(r.group.clone());
545
    }
5465×
    (shown, pages)
5475×
}
548
549
/// An iteration as the board shows it (#343).
550
pub struct IterationView {
551
    pub it: pdb::Iteration,
552
    /// Its first and last days, YYYY-MM-DD.
553
    pub first: String,
554
    pub last: String,
555
    pub days: i64,
556
    /// Its days so far (all of them once it has ended).
557
    pub so_far: usize,
558
    /// Cards done in it so far.
559
    pub done: usize,
560
    /// The burn-up as SVG polyline points in a [`BURN_W`] × [`BURN_H`] box,
561
    /// one point per day so far; `top` is the count at the top of the box.
562
    pub points: String,
563
    pub top: usize,
564
}
565
566
pub const BURN_W: i64 = 280;
567
pub const BURN_H: i64 = 64;
568
569
/// How many past iterations the board lists.
570
const PAST_SHOWN: usize = 12;
571
572
const DAY_MS: i64 = 86_400_000;
573
574
/// `YYYY-MM-DD` to the midnight (UTC) that starts it.
5758×
fn day_ms(s: &str) -> Option<i64> {
57622×
    let mut parts = s.trim().splitn(3, '-').map(|p| p.parse::<i32>().ok());
5778×
    let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??);
5787×
    let month = time::Month::try_from(u8::try_from(m).ok()?).ok()?;
5797×
    let date = time::Date::from_calendar_date(y, month, u8::try_from(d).ok()?).ok()?;
5807×
    Some(date.midnight().assume_utc().unix_timestamp() * 1000)
5818×
}
582
583
/// The day (UTC) holding `ms`, as `YYYY-MM-DD`.
584136×
fn day_of(ms: i64) -> String {
585136×
    let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
586136×
    format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
587136×
}
588
58911×
async fn iteration_view(app: &Shared, it: pdb::Iteration, done: Option<i64>, now: i64) -> sqlx::Result<IterationView> {
59011×
    let times = pdb::done_times(&app.db, &it, done).await?;
59111×
    let days = pdb::iteration_days(&it);
59211×
    let up = pdb::burn_up(&times, it.start_ms, it.end_ms, it.closed_ms.unwrap_or(now));
59311×
    let top = up.last().copied().unwrap_or(0).max(1);
594
    // From nothing at the start to each day's end.
59511×
    let points = std::iter::once((0, 0))
59644×
        .chain(up.iter().enumerate().map(|(d, &n)| (d as i64 + 1, n)))
59755×
        .map(|(d, n)| format!("{},{}", d * BURN_W / days, BURN_H - n as i64 * BURN_H / top as i64))
59811×
        .collect::<Vec<_>>()
59911×
        .join(" ");
60011×
    Ok(IterationView {
60111×
        first: day_of(it.start_ms),
60211×
        last: day_of(it.end_ms - 1),
60311×
        days,
60411×
        so_far: up.len(),
60511×
        done: times.len(),
60611×
        points,
60711×
        top,
60811×
        it,
60911×
    })
61011×
}
611
612
#[derive(Template)]
613
#[template(path = "project.html")]
614
pub struct BoardPage {
615
    shell: Shell,
616
    project: pdb::Project,
617
    columns: Vec<ColumnView>,
618
    /// The owner's repositories the viewer can read, for adding cards.
619
    repos: Vec<String>,
620
    /// Changes cards and columns (an open project).
621
    can_edit: bool,
622
    /// Changes the settings (to reopen a closed project too).
623
    can_manage: bool,
624
    notice: Option<String>,
625
    error: Option<String>,
626
    /// Owners the viewer may move the project to (#125): themselves and the
627
    /// organisations they belong to, but not its owner now.
628
    transfer_to: Vec<String>,
629
    /// The filter the page shows (#293), and whether one is set.
630
    q: String,
631
    state: String,
632
    filtered: bool,
633
    /// The archive view: archived cards, each restorable (#293).
634
    archived: bool,
635
    /// Archived cards on the whole board.
636
    archived_total: usize,
637
    /// The open iteration, the past ones (newest first), and the one the
638
    /// page shows the cards of (#343).
639
    current: Option<IterationView>,
640
    past: Vec<IterationView>,
641
    shown_iteration: Option<IterationView>,
642
    /// A new iteration's suggested first and last days: today and 13 days on.
643
    next_first: String,
644
    next_last: String,
645
    /// The saved views as tabs, and the one shown (#361).
646
    views: Vec<pdb::View>,
647
    view_name: String,
648
    /// `board` or `table`; lanes or groups; the table's order; compact cards.
649
    layout: String,
650
    lane: String,
651
    sort: String,
652
    compact: bool,
653
    /// The board in swimlanes, when `lane` is set.
654
    lanes: Vec<LaneView>,
655
    /// The table's rows, page and pages.
656
    rows: Vec<RowView>,
657
    page: usize,
658
    pages: usize,
659
    /// The address's query for this look, without the page, for links.
660
    look: String,
661
}
662
663
#[allow(clippy::too_many_arguments)]
66459×
async fn board(
66559×
    app: &Shared,
66659×
    shell: Shell,
66759×
    viewer: &Viewer,
66859×
    owner: &str,
66959×
    number: i64,
67059×
    notice: Option<String>,
67159×
    error: Option<String>,
67259×
    view: &BoardQuery,
67359×
) -> Result<Page<BoardPage>, Error> {
67459×
    let e = |e| Error::from_db(shell.clone(), e);
67559×
    let r = rights(app, &shell, viewer, owner).await?;
67659×
    let project = pdb::get(&app.db, owner, number)
67759×
        .await
67859×
        .map_err(e)?
67959×
        .filter(|p| p.public || r.member)
68059×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
68157×
    let now = db::now_ms();
68257×
    let views = pdb::views(&app.db, project.id).await.map_err(e)?;
68357×
    let saved = view.view.as_deref().and_then(|n| views.iter().find(|v| v.name == n));
68457×
    let look = view.over(saved);
68557×
    let view = &look;
68657×
    let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?;
68757×
    let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned());
688
    // A past iteration's page is the archive of what was done in it.
68957×
    let archived = view.archived() || shown.is_some();
69057×
    let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?;
69157×
    if let Some(it) = &shown {
6926×
        for col in columns.iter_mut() {
6936×
            col.cards.retain(|c| c.card.iteration_id == Some(it.id));
694
        }
69555×
    }
696165×
    let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>());
69757×
    let open_since = iterations.iter().find(|i| i.closed_ms.is_none()).map(|i| i.start_ms);
69857×
    shape(&mut columns, view, now, done, open_since);
69957×
    let archived_total = columns.iter().map(|c| c.archived).sum();
70057×
    let (layout, lane, sort) = (view.layout().to_string(), view.lane().to_string(), view.sort.clone().unwrap_or_default());
70157×
    let lanes = if layout == "board" && !lane.is_empty() { lanes(&columns, &lane) } else { Vec::new() };
70257×
    let (rows, pages) = if layout == "table" { rows(&columns, &lane, &sort, view.page.unwrap_or(1)) } else { (Vec::new(), 1) };
70357×
    let page = view.page.unwrap_or(1).clamp(1, pages);
70457×
    let mut parts: Vec<String> = Vec::new();
705342×
    for (k, v) in [
70657×
        ("view", view.view.clone().unwrap_or_default()),
70757×
        ("layout", if layout == "table" { layout.clone() } else { String::new() }),
70857×
        ("q", view.text()),
70957×
        ("lane", lane.clone()),
71057×
        ("sort", sort.clone()),
71157×
        ("compact", if view.compact() { "1".into() } else { String::new() }),
712
    ] {
713342×
        if !v.is_empty() {
71413×
            parts.push(format!("{k}={}", super::settings::urlencode(&v)));
715329×
        }
716
    }
71757×
    let look_query = parts.join("&");
71857×
    let (mut current, mut past) = (None, Vec::new());
71957×
    for it in iterations {
7209×
        if it.closed_ms.is_none() {
7213×
            current = Some(iteration_view(app, it, done, now).await.map_err(e)?);
7226×
        } else if past.len() < PAST_SHOWN {
7236×
            past.push(iteration_view(app, it, done, now).await.map_err(e)?);
7240×
        }
725
    }
72657×
    let shown_iteration = match shown {
7272×
        Some(it) => Some(iteration_view(app, it, done, now).await.map_err(e)?),
72855×
        None => None,
729
    };
73057×
    let can_edit = r.edit && !project.closed;
73157×
    let mut transfer_to = Vec::new();
73257×
    if let (true, Some(a)) = (r.edit, viewer.account()) {
73356×
        transfer_to.push(a.username.clone());
73456×
        let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?;
73556×
        transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name));
73660×
        transfer_to.retain(|o| o != owner);
7371×
    }
73857×
    let (q, state) = (view.text(), view.state());
73957×
    let filtered = !q.is_empty() || !state.is_empty();
740
    Ok(Page(BoardPage {
74157×
        shell,
74257×
        project,
74357×
        columns,
74457×
        repos,
74557×
        can_edit,
74657×
        can_manage: r.edit,
74757×
        notice,
74857×
        error,
74957×
        transfer_to,
75057×
        q,
75157×
        state,
75257×
        filtered,
75357×
        archived,
75457×
        archived_total,
75557×
        current,
75657×
        past,
75757×
        shown_iteration,
75857×
        next_first: day_of(now),
75957×
        next_last: day_of(now + 13 * DAY_MS),
76057×
        view_name: view.view.clone().filter(|n| views.iter().any(|v| &v.name == n)).unwrap_or_default(),
76157×
        views,
76257×
        layout,
76357×
        lane,
76457×
        sort,
76557×
        compact: view.compact(),
76657×
        lanes,
76757×
        rows,
76857×
        page,
76957×
        pages,
77057×
        look: look_query,
771
    }))
77259×
}
773
774
/// A project's columns with the cards `account` may see, and the owner's
775
/// repositories it can read (for adding cards). `archived`: the archived
776
/// cards instead of those on the board (#293); each column counts its
777
/// archived cards either way.
77892×
pub async fn board_columns(
77992×
    app: &Shared,
78092×
    account: Option<&db::accounts::Account>,
78192×
    owner: &str,
78292×
    project: &pdb::Project,
78392×
    archived: bool,
78492×
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
78592×
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
786108×
    for row in db::repos::list_by_owner(&app.db, owner).await? {
787108×
        let role = roles::role_for(&app.db, account, &row).await?;
788108×
        if roles::allows(role, Access::Read) {
78994×
            readable.insert(row.id, row);
79094×
        }
791
    }
79292×
    let all = pdb::cards(&app.db, project.id).await?;
7931185×
    let archived_in = |column: i64| all.iter().filter(|c| c.column_id == column && c.archived_ms.is_some()).count();
794399×
    let cards: Vec<&pdb::Card> = all.iter().filter(|c| c.archived_ms.is_some() == archived).collect();
795
    // What the cards show, read once per repository, not per card: a
796
    // board of 300 cards made 300 queries.
79792×
    let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default();
79892×
    let mut repo_ids: Vec<(i64, bool)> =
799354×
        cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect();
80092×
    repo_ids.sort();
80192×
    repo_ids.dedup();
80292×
    for (rid, issues) in repo_ids {
803154×
        shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s)));
804
    }
80592×
    let mut columns = Vec::new();
806273×
    for column in pdb::columns(&app.db, project.id).await? {
807273×
        let mut views = Vec::new();
8081050×
        for card in cards.iter().copied().filter(|c| c.column_id == column.id) {
809354×
            match (card.kind.as_str(), card.repo_id, card.number) {
810354×
                ("note", _, _) => views.push(CardView {
811241×
                    card: card.clone(),
812241×
                    reference: String::new(),
813241×
                    url: String::new(),
814241×
                    title: card.note.clone(),
815241×
                    state: String::new(),
816241×
                    labels: Vec::new(),
817241×
                    assignees: Vec::new(),
818241×
                    repo: String::new(),
819241×
                    milestone: String::new(),
820241×
                    opened_ms: card.created_ms,
821241×
                }),
822113×
                (kind, Some(rid), Some(n)) => {
823
                    // Cards of repositories the viewer cannot read are not shown at all.
824113×
                    let Some(row) = readable.get(&rid) else { continue };
825111×
                    let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue };
826111×
                    let slug = format!("{}/{}", row.owner, row.name);
827111×
                    views.push(CardView {
828111×
                        card: card.clone(),
829111×
                        reference: format!("{slug}#{n}"),
830111×
                        url: format!("/{slug}/{kind}/{n}"),
831111×
                        title: s.title.clone(),
832111×
                        state: s.state.clone(),
833111×
                        labels: s.labels.clone(),
834111×
                        assignees: s.assignees.clone(),
835111×
                        repo: row.name.clone(),
836111×
                        milestone: s.milestone.clone(),
837111×
                        opened_ms: s.opened_ms,
838111×
                    });
839
                }
8400×
                _ => {}
841
            }
842
        }
843273×
        let total = views.len();
844273×
        let archived = archived_in(column.id);
845352×
        let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect();
846273×
        let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed"));
847273×
        columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished, over_wip: false, before_iteration: 0 });
848
    }
84994×
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
85092×
    repos.sort();
85192×
    Ok((columns, repos))
85292×
}
853
854
/// What a card shows of an issue or a proposal.
855
struct Shown {
856
    title: String,
857
    state: String,
858
    labels: Vec<idb::Label>,
859
    assignees: Vec<String>,
860
    milestone: String,
861
    opened_ms: i64,
862
}
863
864
/// A repository's issues (or proposals) as cards show them, by number.
86581×
async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result<Vec<(i64, Shown)>> {
866
    use std::collections::HashMap;
86781×
    let mut out = Vec::new();
86881×
    if issues {
86970×
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
87070×
        for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? {
87118×
            labels.entry(id).or_default().push(l);
87218×
        }
87370×
        let mut assignees: HashMap<i64, Vec<String>> = HashMap::new();
87470×
        for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? {
87528×
            assignees.entry(id).or_default().push(u);
87628×
        }
87770×
        let milestones: HashMap<i64, String> = idb::milestones(&app.db, repo_id).await?.into_iter().map(|m| (m.id, m.title)).collect();
878143×
        for i in idb::list(&app.db, repo_id).await? {
879143×
            let mut a = assignees.remove(&i.id).unwrap_or_default();
880143×
            a.sort();
881143×
            let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()).unwrap_or_default();
882143×
            let s = Shown {
883143×
                labels: labels.remove(&i.id).unwrap_or_default(),
884143×
                assignees: a,
885143×
                title: i.title,
886143×
                state: i.state,
887143×
                milestone,
888143×
                opened_ms: i.created_ms,
889143×
            };
890143×
            out.push((i.number, s));
891
        }
892
    } else {
89311×
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
89411×
        for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? {
8950×
            labels.entry(id).or_default().push(l);
8960×
        }
89711×
        for p in db::proposals::list(&app.db, repo_id, &[]).await? {
89811×
            let s = Shown {
89911×
                labels: labels.remove(&p.id).unwrap_or_default(),
90011×
                assignees: Vec::new(),
90111×
                title: p.title,
90211×
                state: p.state,
90311×
                milestone: String::new(),
90411×
                opened_ms: p.created_ms,
90511×
            };
90611×
            out.push((p.number, s));
90711×
        }
908
    }
90981×
    Ok(out)
91081×
}
911
91239×
pub async fn show(
91339×
    State(app): State<Shared>,
91439×
    viewer: Viewer,
91539×
    Path((owner, number)): Path<(String, i64)>,
91639×
    Query(view): Query<BoardQuery>,
91739×
) -> Result<Response, Error> {
91839×
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
919
    // A board transferred away (#125): its old address goes to its new one.
92039×
    let e = |e| Error::from_db(shell.clone(), e);
92139×
    if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() {
9222×
        if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? {
9231×
            return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response());
9241×
        }
92537×
    }
92638×
    Ok(board(&app, shell, &viewer, &owner, number, None, None, &view).await?.into_response())
92739×
}
928
929
#[derive(Deserialize)]
930
pub struct BoardForm {
931
    #[serde(rename = "_csrf")]
932
    csrf_token: String,
933
    action: String,
934
    #[serde(default)]
935
    card: Option<i64>,
936
    #[serde(default)]
937
    column: Option<i64>,
938
    #[serde(default)]
939
    name: String,
940
    /// `repo#12`, or `#12` with `repo`.
941
    #[serde(default)]
942
    reference: String,
943
    #[serde(default)]
944
    repo: String,
945
    #[serde(default)]
946
    note: String,
947
    /// Automatic archiving: `finished` (the default), `days` or `never`.
948
    #[serde(default)]
949
    mode: String,
950
    #[serde(default)]
951
    title: String,
952
    #[serde(default)]
953
    description: String,
954
    #[serde(default)]
955
    private: Option<String>,
956
    #[serde(default)]
957
    closed: Option<String>,
958
    /// The owner to transfer the project to (#125).
959
    #[serde(default)]
960
    to: String,
961
    /// Days, for archiving a column's older cards (#293); empty is never.
962
    #[serde(default)]
963
    days: String,
964
    /// The iteration to close, and a new one's first and last days as
965
    /// YYYY-MM-DD (#343).
966
    #[serde(default)]
967
    iteration: Option<i64>,
968
    #[serde(default)]
969
    start: String,
970
    #[serde(default)]
971
    end: String,
972
    /// A column's work-in-progress limit; empty for none (#361).
973
    #[serde(default)]
974
    limit: String,
975
    /// A view to save (with `name`): its layout, filter, lanes and order;
976
    /// or the id of one to delete (#361).
977
    #[serde(default)]
978
    layout: String,
979
    #[serde(default)]
980
    q: String,
981
    #[serde(default)]
982
    lane: String,
983
    #[serde(default)]
984
    sort: String,
985
    #[serde(default)]
986
    view: Option<i64>,
987
}
988
989
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
990
/// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the
991
/// board's (the form the board itself shows, oxygenhub#118). `Err` says
992
/// what is wrong.
99326×
fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> {
99426×
    let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string();
99526×
    let r = reference.trim();
99626×
    let (name, n) = r.split_once('#').ok_or_else(bad)?;
99725×
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
99825×
    let name = match name.split_once('/') {
9994×
        Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest,
10002×
        Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")),
100121×
        None => name,
1002
    };
100323×
    let n: i64 = n.trim().parse().map_err(|_| bad())?;
100423×
    if name.is_empty() || n <= 0 || name.contains('/') {
10052×
        return Err(bad());
100621×
    }
100721×
    Ok((name.to_string(), n))
100826×
}
1009
1010
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
1011
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
1012
/// should be told: an unknown repository or number, or a card already there.
1013
#[allow(clippy::too_many_arguments)]
101422×
pub async fn add_card(
101522×
    app: &Shared,
101622×
    account: Option<&db::accounts::Account>,
101722×
    owner: &str,
101822×
    project_id: i64,
101922×
    column_id: i64,
102022×
    reference: &str,
102122×
    repo: &str,
102222×
    note: &str,
102322×
    actor: &str,
102422×
) -> sqlx::Result<Result<pdb::Card, String>> {
102522×
    if !note.trim().is_empty() {
10264×
        let note: String = note.trim().chars().take(500).collect();
10274×
        pdb::add_card(
10284×
            &app.db,
10294×
            project_id,
10304×
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
10314×
        )
10324×
        .await?;
10334×
        return Ok(Ok(newest_card(app, project_id).await?));
103418×
    }
103518×
    let (repo, n) = match parse_reference(reference, repo, owner) {
103617×
        Ok(r) => r,
10371×
        Err(why) => return Ok(Err(why)),
1038
    };
103917×
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
104016×
    let role = roles::role_for(&app.db, account, &row).await?;
104116×
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
104216×
    let kind = if issues.contains(&n) {
104313×
        "issue"
10443×
    } else if proposals.contains(&n) {
10452×
        "proposal"
1046
    } else {
10471×
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
1048
    };
104915×
    if !roles::allows(role, Access::Read) {
10500×
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
105115×
    }
105215×
    if !pdb::add_card(
105315×
        &app.db,
105415×
        project_id,
105515×
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
105615×
    )
105715×
    .await?
1058
    {
10594×
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
106011×
    }
106111×
    Ok(Ok(newest_card(app, project_id).await?))
106222×
}
1063
106415×
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
106515×
    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
106615×
}
1067
106842×
pub async fn act(
106942×
    State(app): State<Shared>,
107042×
    viewer: Viewer,
107142×
    Path((owner, number)): Path<(String, i64)>,
107242×
    Form(f): Form<BoardForm>,
107342×
) -> Result<Response, Error> {
107442×
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
107542×
    csrf(&viewer, &f.csrf_token, &shell)?;
107642×
    let r = rights(&app, &shell, &viewer, &owner).await?;
107742×
    let e = |e| Error::from_db(shell.clone(), e);
107842×
    let project = pdb::get(&app.db, &owner, number)
107942×
        .await
108042×
        .map_err(e)?
108142×
        .filter(|p| p.public || r.member)
108242×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
108342×
    if !r.edit {
10841×
        return Err(Error::not_found(shell));
108541×
    }
108641×
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") {
10871×
        return Ok(board(
10881×
            &app,
10891×
            shell,
10901×
            &viewer,
10911×
            &owner,
10921×
            number,
10931×
            None,
10941×
            Some("This project is closed; reopen it to change it.".into()),
10951×
            &BoardQuery::default(),
10961×
        )
10971×
        .await?
10981×
        .into_response());
109940×
    }
110040×
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
110140×
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
110247×
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
110340×
    let mut notice = None;
110440×
    let mut error = None;
110540×
    match f.action.as_str() {
110640×
        "add" => {
110711×
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
11083×
            if let Err(msg) =
110911×
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
11103×
            {
11113×
                error = Some(msg);
11128×
            }
1113
        }
111429×
        "move" => {
11151×
            if let (Some(card), Some(col)) = (f.card, f.column) {
11161×
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
11170×
            }
1118
        }
111928×
        "up" | "down" => {
11200×
            if let Some(card) = f.card {
11210×
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
11220×
            }
1123
        }
112428×
        "remove" => {
11250×
            if let Some(card) = f.card {
11260×
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
11270×
            }
1128
        }
112928×
        "archive" => {
11301×
            if let Some(card) = f.card {
11311×
                pdb::archive_cards(&app.db, project.id, &[card]).await.map_err(e)?;
11320×
            }
1133
        }
113427×
        "restore" => {
11352×
            if let Some(card) = f.card {
11362×
                pdb::restore_card(&app.db, project.id, card).await.map_err(e)?;
11370×
            }
11382×
            return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response());
1139
        }
114025×
        "takes_finished" => {
11412×
            let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) };
11425×
            let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default();
11432×
            let on = f.mode == "on";
11442×
            pdb::set_takes_finished(&app.db, project.id, on.then_some(col)).await.map_err(e)?;
11452×
            notice = Some(if on {
11461×
                format!("Cards move to {name} when their issue closes or their proposal lands or closes.")
1147
            } else {
11481×
                "Cards stay where they are when their issue closes or their proposal lands.".into()
1149
            });
1150
        }
115123×
        "archive_older" | "archive_after" => {
11525×
            let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) };
11535×
            let days = match (f.action.as_str(), f.mode.as_str(), f.days.trim()) {
11545×
                ("archive_after", "finished", _) | ("archive_after", "", "") => None,
11551×
                ("archive_after", "never", _) => Some(pdb::NEVER),
11563×
                (_, _, d) => match d.parse::<i64>() {
11572×
                    Ok(n) if (0..=3650).contains(&n) => Some(n),
1158
                    _ => {
11591×
                        error = Some("Give a number of days, 0 to 3650.".into());
11601×
                        None
1161
                    }
1162
                },
1163
            };
116415×
            let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default();
11655×
            if error.is_none() && f.action == "archive_after" {
11663×
                pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?;
11673×
                notice = Some(match days {
11681×
                    Some(pdb::NEVER) => format!("{name} keeps its cards until you archive them."),
11691×
                    Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }),
11701×
                    None => format!("{name} archives finished cards {} days after they reach it.", pdb::FINISHED_KEPT_DAYS),
1171
                });
11722×
            } else if let (None, Some(days)) = (&error, days) {
11731×
                let now = db::now_ms();
11741×
                let ids: Vec<i64> = pdb::cards(&app.db, project.id)
11751×
                    .await
11761×
                    .map_err(e)?
11771×
                    .into_iter()
11784×
                    .filter(|c| c.column_id == col && c.archived_ms.is_none() && pdb::older_than(c, days, now))
11791×
                    .map(|c| c.id)
11801×
                    .collect();
11811×
                let n = pdb::archive_cards(&app.db, project.id, &ids).await.map_err(e)?;
11821×
                let detail = format!("{n} card(s) of {name} older than {days} days");
11831×
                db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok();
11841×
                notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" }));
11851×
            }
1186
        }
118718×
        "start_iteration" => {
11884×
            let name: String = f.name.trim().chars().take(60).collect();
11894×
            match (day_ms(&f.start), day_ms(&f.end)) {
11904×
                _ if name.is_empty() => error = Some("Name the iteration.".into()),
11913×
                (Some(start), Some(last)) if last >= start && last - start < 366 * DAY_MS => {
11922×
                    match pdb::start_iteration(&app.db, project.id, &name, start, last + DAY_MS).await.map_err(e)? {
11931×
                        Some(_) => notice = Some(format!("{name} started.")),
11941×
                        None => error = Some("Close the open iteration before starting another.".into()),
1195
                    }
1196
                }
11971×
                (Some(_), Some(_)) => error = Some("An iteration ends on or after its first day, within a year.".into()),
11981×
                _ => error = Some("Give the first and last days as dates.".into()),
1199
            }
1200
        }
120114×
        "close_iteration" => {
12022×
            let Some(id) = f.iteration else { return Err(Error::bad(shell, "no iteration")) };
12032×
            match pdb::close_iteration(&app.db, project.id, id, pdb::done_column(&columns)).await.map_err(e)? {
12041×
                Some(n) => {
12051×
                    let detail = format!("iteration {id}: {n} done card(s) archived");
12061×
                    db::accounts::audit(&app.db, viewer.account(), "project.iteration_closed", &format!("{owner}/{number}"), &detail)
12071×
                        .await
12081×
                        .ok();
12091×
                    notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" }));
1210
                }
12111×
                None => error = Some("That iteration is not open.".into()),
1212
            }
1213
        }
121412×
        "wip" => {
12152×
            let limit = f.limit.trim();
12162×
            match (column, limit.parse::<i64>()) {
12172×
                (Some(col), _) if limit.is_empty() => pdb::set_wip_limit(&app.db, project.id, col, None).await.map_err(e)?,
12181×
                (Some(col), Ok(n)) if (0..=999).contains(&n) => pdb::set_wip_limit(&app.db, project.id, col, Some(n)).await.map_err(e)?,
12190×
                _ => error = Some("A work-in-progress limit is a number of cards, 0 to 999, or empty for none.".into()),
1220
            }
1221
        }
122210×
        "save_view" => {
12231×
            let name: String = f.name.trim().chars().take(40).collect();
12241×
            let layout = if f.layout == "table" { "table" } else { "board" };
12251×
            let lane = if matches!(f.lane.as_str(), "repo" | "assignee" | "label" | "milestone") { f.lane.as_str() } else { "" };
12261×
            let sort: String = f.sort.trim().chars().take(20).collect();
12271×
            let q: String = f.q.trim().chars().take(300).collect();
12281×
            if name.is_empty() {
12290×
                error = Some("Name the view.".into());
12300×
            } else {
12311×
                pdb::save_view(&app.db, project.id, &name, layout, &q, lane, &sort).await.map_err(e)?;
12321×
                return Ok(Redirect::to(&format!("/projects/{owner}/{number}?view={}", super::settings::urlencode(&name))).into_response());
1233
            }
1234
        }
12359×
        "delete_view" => {
12361×
            if let Some(id) = f.view {
12371×
                pdb::delete_view(&app.db, project.id, id).await.map_err(e)?;
12380×
            }
1239
        }
12408×
        "add_column" => {
12410×
            let name = f.name.trim();
12420×
            if name.is_empty() || name.len() > 40 {
12430×
                error = Some("A column has a name of up to 40 characters.".into());
12440×
            } else {
12450×
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
1246
            }
1247
        }
12488×
        "rename_column" => {
12490×
            if let (Some(col), name) = (f.column, f.name.trim()) {
12500×
                if !name.is_empty() && name.len() <= 40 {
12510×
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
12520×
                }
12530×
            }
1254
        }
12558×
        "left" | "right" => {
12560×
            if let Some(col) = f.column {
12570×
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
12580×
            }
1259
        }
12608×
        "remove_column" => {
12613×
            if let Some(col) = f.column {
12623×
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
12631×
                    error = Some("A project keeps at least one column.".into());
12642×
                }
12650×
            }
1266
        }
12675×
        "settings" => {
12682×
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
12692×
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
12702×
            notice = Some("Saved.".into());
1271
        }
12723×
        "transfer" => {
12732×
            let to = f.to.trim();
12742×
            match rights_of(&app, viewer.account(), to).await.map_err(e)? {
12752×
                _ if to == owner => error = Some(format!("It is {owner}'s already.")),
12761×
                Some(t) if t.edit => {
12771×
                    let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?;
12781×
                    let detail = format!("{owner}/{number} to {to}/{}", moved.number);
12791×
                    db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok();
12801×
                    return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response());
1281
                }
12821×
                _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")),
1283
            }
1284
        }
12851×
        "delete" => {
12861×
            pdb::delete(&app.db, project.id).await.map_err(e)?;
12871×
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
12881×
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
1289
        }
12900×
        _ => return Err(Error::bad(shell, "unknown action")),
1291
    }
129235×
    if notice.is_none() && error.is_none() {
1293
        // Back to the board, so a reload does not repeat the action.
129415×
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
129520×
    }
129620×
    Ok(board(&app, shell, &viewer, &owner, number, notice, error, &BoardQuery::default()).await?.into_response())
129742×
}
1298
1299
/// The projects an issue or proposal is on that the viewer may see, with
1300
/// their column.
1301106×
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
1302106×
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
1303106×
    let mut out = Vec::new();
1304106×
    for (p, col) in all {
13055×
        let visible = p.public
13061×
            || viewer.is_admin()
13071×
            || match viewer.account() {
13080×
                Some(v) if v.username == p.owner => true,
13090×
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
13100×
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
13110×
                    None => false,
1312
                },
13131×
                None => false,
1314
            };
13155×
        if visible {
13164×
            out.push((p, col));
13174×
        }
1318
    }
1319106×
    out
1320106×
}
1321
1322
#[cfg(test)]
1323
mod tests {
1324
    use super::parse_reference;
1325
1326
    #[test]
13271×
    fn references() {
13287×
        let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok();
13291×
        assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12)));
13301×
        assert_eq!(p("#7", "hub"), Some(("hub".into(), 7)));
13311×
        assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3)));
13321×
        assert_eq!(p("#7", ""), None, "a repository is needed");
13331×
        assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards");
13341×
        assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why");
13351×
        assert_eq!(p("hub#0", ""), None);
13361×
        assert_eq!(p("hub", ""), None);
13371×
    }
1338
}
1339
1340
#[derive(Deserialize)]
1341
pub struct PlaceForm {
1342
    #[serde(rename = "_csrf")]
1343
    csrf_token: String,
1344
    /// The project's number among its owner's.
1345
    project: i64,
1346
    /// `add` or `remove`.
1347
    action: String,
1348
}
1349
1350
/// Put an issue or proposal on one of its repository owner's projects (in
1351
/// the first column), or take it off, from its own page.
13522×
async fn place(
13532×
    app: Shared,
13542×
    viewer: Viewer,
13552×
    owner: String,
13562×
    name: String,
13572×
    kind: &'static str,
13582×
    number: i64,
13592×
    f: PlaceForm,
13602×
) -> Result<Response, Error> {
13612×
    let page = format!("/{owner}/{name}/{kind}/{number}");
13622×
    let shell = Shell::new(&app, &page, &viewer);
13632×
    csrf(&viewer, &f.csrf_token, &shell)?;
13642×
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
13652×
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
13662×
    if !r.edit {
13670×
        return Err(Error::not_found(shell));
13682×
    }
13692×
    let e = |e| Error::from_db(shell.clone(), e);
13702×
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
13712×
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
13722×
    let mut error = None;
13732×
    match f.action.as_str() {
13742×
        "add" => {
13751×
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
13761×
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
13770×
            if let Err(msg) =
13781×
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
13791×
                    .await
13801×
                    .map_err(e)?
13810×
            {
13820×
                error = Some(msg);
13831×
            }
1384
        }
13851×
        "remove" => {
13861×
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
13871×
                if c.repo_id == Some(row.id) && c.number == Some(number) {
13881×
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
13890×
                }
1390
            }
1391
        }
13920×
        _ => return Err(Error::bad(shell, "add or remove")),
1393
    }
13942×
    let to = match error {
13950×
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
13962×
        None => page,
1397
    };
13982×
    Ok(Redirect::to(&to).into_response())
13992×
}
1400
1401
/// `POST /{owner}/{name}/proposal/{number}/project`.
14022×
pub async fn place_proposal(
14032×
    State(app): State<Shared>,
14042×
    viewer: Viewer,
14052×
    Path((owner, name, number)): Path<(String, String, i64)>,
14062×
    Form(f): Form<PlaceForm>,
14072×
) -> Result<Response, Error> {
14082×
    place(app, viewer, owner, name, "proposal", number, f).await
14092×
}
1410
1411
/// `POST /{owner}/{name}/issue/{number}/project`.
14120×
pub async fn place_issue(
14130×
    State(app): State<Shared>,
14140×
    viewer: Viewer,
14150×
    Path((owner, name, number)): Path<(String, String, i64)>,
14160×
    Form(f): Form<PlaceForm>,
14170×
) -> Result<Response, Error> {
14180×
    place(app, viewer, owner, name, "issue", number, f).await
14190×
}
1420
1421
/// The owner's open projects `viewer` may add an item to that it is not on
1422
/// yet; `None` when the viewer may not change the owner's projects at all.
1423106×
pub async fn project_choices(
1424106×
    app: &Shared,
1425106×
    viewer: &Viewer,
1426106×
    repo: &db::repos::RepoRow,
1427106×
    placed: &[(pdb::Project, String)],
1428106×
) -> Option<Vec<pdb::Project>> {
1429106×
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
1430106×
    if !r.edit {
143145×
        return None;
143261×
    }
1433
    Some(
143461×
        pdb::of_owner(&app.db, &repo.owner)
143561×
            .await
143661×
            .unwrap_or_default()
143761×
            .into_iter()
143861×
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
143961×
            .collect(),
1440
    )
1441106×
}
1442
1443
#[cfg(test)]
1444
mod view_tests {
1445
    use super::*;
1446
1447
    const NOW: i64 = 100 * DAY_MS;
1448
1449
    #[allow(clippy::too_many_arguments)]
1450166×
    fn card(
1451166×
        id: i64,
1452166×
        title: &str,
1453166×
        repo: &str,
1454166×
        state: &str,
1455166×
        assignees: &[&str],
1456166×
        labels: &[&str],
1457166×
        milestone: &str,
1458166×
        age_days: i64,
1459166×
    ) -> CardView {
1460
        CardView {
1461166×
            card: pdb::Card {
1462166×
                id,
1463166×
                project_id: 1,
1464166×
                column_id: 1,
1465166×
                position: id,
1466166×
                kind: "issue".into(),
1467166×
                repo_id: Some(1),
1468166×
                number: Some(id),
1469166×
                note: String::new(),
1470166×
                added_by: String::new(),
1471166×
                created_ms: 0,
1472166×
                archived_ms: None,
1473166×
                moved_ms: NOW - age_days * DAY_MS,
1474166×
                iteration_id: None,
1475166×
            },
1476166×
            reference: format!("andy/{repo}#{id}"),
1477166×
            url: format!("/andy/{repo}/issue/{id}"),
1478166×
            title: title.into(),
1479166×
            state: state.into(),
1480166×
            labels: labels
1481166×
                .iter()
1482166×
                .map(|l| idb::Label { id: 0, repo_id: 1, name: (*l).into(), colour: "#123456".into(), description: String::new() })
1483166×
                .collect(),
1484166×
            assignees: assignees.iter().map(|a| (*a).to_string()).collect(),
1485166×
            repo: repo.into(),
1486166×
            milestone: milestone.into(),
1487166×
            opened_ms: NOW - age_days * DAY_MS,
1488
        }
1489166×
    }
1490
149131×
    fn column(id: i64, name: &str, wip: Option<i64>, cards: Vec<CardView>) -> ColumnView {
149231×
        ColumnView {
149331×
            column: pdb::Column {
149431×
                id,
149531×
                project_id: 1,
149631×
                name: name.into(),
149731×
                position: id,
149831×
                archive_after_days: None,
149931×
                takes_finished: name == "Done",
150031×
                wip_limit: wip,
150131×
            },
150231×
            total: cards.len(),
150331×
            cards,
150431×
            hidden: 0,
150531×
            archived: 0,
150631×
            finished: false,
150731×
            over_wip: false,
150831×
            before_iteration: 0,
150931×
        }
151031×
    }
1511
151215×
    fn board() -> Vec<ColumnView> {
151315×
        vec![
151415×
            column(
1515
                1,
151615×
                "Doing",
151715×
                Some(1),
151815×
                vec![
151915×
                    card(1, "Crash on start", "oxy", "open", &["andy"], &["bug"], "Release 1", 20),
152015×
                    card(2, "Faster pull", "hub", "open", &[], &["perf", "bug"], "", 3),
1521
                ],
1522
            ),
152315×
            column(2, "Done", None, vec![card(3, "Docs", "oxy", "closed", &["kim"], &[], "Release 1", 40)]),
1524
        ]
152515×
    }
1526
152712×
    fn keeps(q: &str) -> Vec<i64> {
152812×
        let f = CardFilter::parse(q);
152936×
        board().iter().flat_map(|c| c.cards.iter()).filter(|c| f.keeps(c, NOW)).map(|c| c.card.id).collect()
153012×
    }
1531
1532
    #[test]
15331×
    fn the_filter_bar_reads_every_term() {
15341×
        assert_eq!(keeps(""), [1, 2, 3]);
15351×
        assert_eq!(keeps("repo:oxy"), [1, 3]);
15361×
        assert_eq!(keeps("repo:andy/hub"), [2]);
15371×
        assert_eq!(keeps("assignee:@andy"), [1]);
15381×
        assert_eq!(keeps("label:bug label:perf"), [2]);
15391×
        assert_eq!(keeps(r#"milestone:"release 1""#), [1, 3]);
15401×
        assert_eq!(keeps("is:open"), [1, 2]);
15411×
        assert_eq!(keeps("is:closed"), [3]);
15421×
        assert_eq!(keeps("age:>14d"), [1, 3]);
15431×
        assert_eq!(keeps("age:<7"), [2]);
15441×
        assert_eq!(keeps("crash age:>14d"), [1]);
15451×
        assert_eq!(keeps("age:soon"), Vec::<i64>::new(), "an unreadable term is a word, which matches nothing");
15461×
        assert!(CardFilter::parse("  ").is_empty());
15471×
    }
1548
1549
    #[test]
15501×
    fn wip_limits_and_this_iterations_done() {
15511×
        let mut cols = board();
15521×
        cols[1].cards.push(card(4, "Old", "oxy", "closed", &[], &[], "", 1));
15531×
        cols[1].cards[1].card.moved_ms = NOW - 30 * DAY_MS;
15541×
        let q = BoardQuery::default();
15551×
        shape(&mut cols, &q, NOW, Some(2), Some(NOW - 50 * DAY_MS + 1));
15561×
        assert!(cols[0].over_wip, "two cards over a limit of one");
15571×
        assert!(!cols[1].over_wip);
1558
        // An iteration that began 50 days ago: card 3 (in Done for 40 days)
1559
        // and card 4 (30) both reached Done during it.
15601×
        assert_eq!((cols[1].cards.len(), cols[1].before_iteration), (2, 0));
15611×
        let mut cols = board();
15621×
        shape(&mut cols, &q, NOW, Some(2), Some(NOW - 10 * DAY_MS));
15631×
        assert_eq!((cols[1].cards.len(), cols[1].before_iteration, cols[1].total), (0, 1, 0), "done before it began");
15641×
    }
1565
1566
    #[test]
15671×
    fn lanes_and_table_rows() {
15681×
        let cols = board();
15693×
        let names: Vec<(String, usize)> = lanes(&cols, "label").into_iter().map(|l| (l.name, l.count)).collect();
15701×
        assert_eq!(names, [("bug".to_string(), 2), ("perf".into(), 1), ("(no label)".into(), 1)]);
15711×
        let by_repo: Vec<String> = lanes(&cols, "repo").into_iter().map(|l| l.name).collect();
15721×
        assert_eq!(by_repo, ["hub", "oxy"]);
15731×
        let (shown, pages) = super::rows(&cols, "", "title", 1);
15741×
        assert_eq!(pages, 1);
15751×
        assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [1, 3, 2]);
15761×
        let (shown, _) = super::rows(&cols, "", "-age", 1);
15771×
        assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [3, 1, 2], "oldest first");
15781×
        let (shown, _) = super::rows(&cols, "assignee", "", 1);
15793×
        let groups: Vec<(&str, bool)> = shown.iter().map(|r| (r.group.as_str(), r.starts_group)).collect();
15801×
        assert_eq!(groups, [("@andy", true), ("@kim", true), ("(nobody)", true)]);
1581
        // Paged: 120 cards are three pages of fifty.
1582120×
        let many = vec![column(1, "Doing", None, (0..120).map(|i| card(i, "x", "oxy", "open", &[], &[], "", 1)).collect())];
15831×
        let (p3, pages) = super::rows(&many, "", "", 3);
15841×
        assert_eq!((p3.len(), pages), (20, 3));
15851×
    }
1586
}