OXYGENHUB

Coverage / crates/hub/src/db/projects.rs 91.4%

448 of 490 lines run, 68 of 148 functions · run #539 on main · run missed

1
//! Projects, their columns and cards (hub spec §8.2).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct Project {
8
    pub id: i64,
9
    pub owner: String,
10
    pub number: i64,
11
    pub title: String,
12
    pub description: String,
13
    pub public: bool,
14
    pub closed: bool,
15
    pub created_ms: i64,
16
    pub updated_ms: i64,
17
}
18
19
#[derive(Clone, Debug, FromRow)]
20
pub struct Column {
21
    pub id: i64,
22
    pub project_id: i64,
23
    pub name: String,
24
    pub position: i64,
25
    /// Its cards are archived this many days after they joined it (#293).
26
    pub archive_after_days: Option<i64>,
27
    /// Cards whose issue closes or whose proposal lands or closes move here
28
    /// (#325); one column of a board at most.
29
    pub takes_finished: bool,
30
}
31
32
#[derive(Clone, Debug, FromRow)]
33
pub struct Card {
34
    pub id: i64,
35
    pub project_id: i64,
36
    pub column_id: i64,
37
    pub position: i64,
38
    pub kind: String,
39
    pub repo_id: Option<i64>,
40
    pub number: Option<i64>,
41
    pub note: String,
42
    pub added_by: String,
43
    pub created_ms: i64,
44
    /// Off the board since then (#293): kept, and restorable.
45
    pub archived_ms: Option<i64>,
46
    /// When it joined its column.
47
    pub moved_ms: i64,
48
    /// The iteration it was archived under when that closed (#343).
49
    pub iteration_id: Option<i64>,
50
}
51
52
/// A named span of a board's days (#343). A board has one open at most;
53
/// closing it archives its Done column's cards under it.
54
#[derive(Clone, Debug, FromRow)]
55
pub struct Iteration {
56
    pub id: i64,
57
    pub project_id: i64,
58
    pub name: String,
59
    pub start_ms: i64,
60
    /// The end, exclusive: the midnight after its last day.
61
    pub end_ms: i64,
62
    pub closed_ms: Option<i64>,
63
}
64
65
/// A day, for the archive's ages.
66
const DAY_MS: i64 = 86_400_000;
67
68
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
69
7011×
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
7111×
    let mut tx = super::begin_write(pool).await?;
7211×
    let number: i64 =
7311×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
7411×
    let now = now_ms();
7511×
    let p: Project = sqlx::query_as(
7611×
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
7711×
    )
7811×
    .bind(owner)
7911×
    .bind(number)
8011×
    .bind(title)
8111×
    .bind(description)
8211×
    .bind(public)
8311×
    .bind(now)
8411×
    .bind(now)
8511×
    .fetch_one(&mut *tx)
8611×
    .await?;
87
    // A board here now: an old address of a transferred one gives way (#125).
8811×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
8933×
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
9033×
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
9133×
            .bind(p.id)
9233×
            .bind(name)
9333×
            .bind(i as i64)
9433×
            .bind(*name == "Done")
9533×
            .execute(&mut *tx)
9633×
            .await?;
97
    }
9811×
    tx.commit().await?;
9911×
    Ok(p)
10011×
}
101
10276×
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
10376×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
10476×
}
105
106
/// Move project `id` to owner `to` (#125): it takes the next number there,
107
/// with its columns, cards and privacy, and its old address redirects to it.
1081×
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
1091×
    let mut tx = super::begin_write(pool).await?;
1101×
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
1111×
    let number: i64 =
1121×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
1131×
    sqlx::query("INSERT INTO project_redirects (owner, number, project_id) VALUES ($1, $2, $3) ON CONFLICT (owner, number) DO UPDATE SET project_id = excluded.project_id")
1141×
        .bind(&old.owner)
1151×
        .bind(old.number)
1161×
        .bind(id)
1171×
        .execute(&mut *tx)
1181×
        .await?;
1191×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
1201×
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
1211×
        .bind(to)
1221×
        .bind(number)
1231×
        .bind(now_ms())
1241×
        .bind(id)
1251×
        .fetch_one(&mut *tx)
1261×
        .await?;
1271×
    tx.commit().await?;
1281×
    Ok(moved)
1291×
}
130
131
/// The project an old address (`owner`, `number`) now redirects to, after
132
/// a transfer (#125).
1332×
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
1342×
    sqlx::query_as("SELECT p.* FROM project_redirects r JOIN projects p ON p.id = r.project_id WHERE r.owner = $1 AND r.number = $2")
1352×
        .bind(owner)
1362×
        .bind(number)
1372×
        .fetch_optional(pool)
1382×
        .await
1392×
}
140
141194×
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
142194×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
143194×
}
144
1456×
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
1466×
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
1476×
        .bind(title)
1486×
        .bind(description)
1496×
        .bind(public)
1506×
        .bind(closed)
1516×
        .bind(now_ms())
1526×
        .bind(id)
1536×
        .execute(pool)
1546×
        .await?;
1556×
    Ok(())
1566×
}
157
1581×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
1591×
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
1601×
    Ok(())
1611×
}
162
16339×
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
16439×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
16539×
    Ok(())
16639×
}
167
168154×
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
169154×
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
170154×
}
171
17286×
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
17386×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
17486×
}
175
1761×
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
1771×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
1781×
        .bind(project_id)
1791×
        .fetch_one(pool)
1801×
        .await?;
1811×
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
1821×
        .bind(project_id)
1831×
        .bind(name)
1841×
        .bind(pos)
1851×
        .execute(pool)
1861×
        .await?;
1871×
    touch(pool, project_id).await
1881×
}
189
1901×
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
1911×
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
1921×
        .bind(name)
1931×
        .bind(column_id)
1941×
        .bind(project_id)
1951×
        .execute(pool)
1961×
        .await?;
1971×
    touch(pool, project_id).await
1981×
}
199
200
/// Swap a column with its neighbour (`-1` left, `1` right).
2010×
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
2020×
    let cols = columns(pool, project_id).await?;
2030×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
2040×
    let j = i as i64 + by;
2050×
    if j < 0 || j as usize >= cols.len() {
2060×
        return Ok(());
2070×
    }
2080×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
2090×
    ids.swap(i, j as usize);
2100×
    for (pos, id) in ids.iter().enumerate() {
2110×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
212
    }
2130×
    touch(pool, project_id).await
2140×
}
215
216
/// Move a column to `position` (0 the leftmost; past the end the rightmost).
2172×
pub async fn place_column(pool: &Pool, project_id: i64, column_id: i64, position: i64) -> sqlx::Result<()> {
2182×
    let cols = columns(pool, project_id).await?;
2195×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
2202×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
2212×
    let id = ids.remove(i);
2222×
    ids.insert((position.max(0) as usize).min(ids.len()), id);
2238×
    for (pos, id) in ids.iter().enumerate() {
2248×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
225
    }
2262×
    touch(pool, project_id).await
2272×
}
228
229
/// Remove a column; its cards go to the first remaining column. The last column stays.
2304×
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
2314×
    let cols = columns(pool, project_id).await?;
2324×
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
2338×
    if !cols.iter().any(|c| c.id == column_id) {
2340×
        return Ok(false);
2353×
    }
2363×
    let mut tx = super::begin_write(pool).await?;
2373×
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2383×
        .bind(to.id)
2393×
        .fetch_one(&mut *tx)
2403×
        .await?;
2413×
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
2423×
        .bind(to.id)
2433×
        .bind(base)
2443×
        .bind(column_id)
2453×
        .execute(&mut *tx)
2463×
        .await?;
2473×
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
2483×
    tx.commit().await?;
2493×
    touch(pool, project_id).await?;
2503×
    Ok(true)
2514×
}
252
253
pub struct NewCard<'a> {
254
    pub column_id: i64,
255
    pub kind: &'a str,
256
    pub repo_id: Option<i64>,
257
    pub number: Option<i64>,
258
    pub note: &'a str,
259
    pub added_by: &'a str,
260
}
261
262
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
263
/// proposal is already on the project.
26419×
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
26519×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
26619×
        .bind(c.column_id)
26719×
        .fetch_one(pool)
26819×
        .await?;
26919×
    let r = sqlx::query(
27019×
        "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING",
27119×
    )
27219×
    .bind(project_id)
27319×
    .bind(c.column_id)
27419×
    .bind(pos)
27519×
    .bind(c.kind)
27619×
    .bind(c.repo_id)
27719×
    .bind(c.number)
27819×
    .bind(c.note)
27919×
    .bind(c.added_by)
28019×
    .bind(now_ms())
28119×
    .execute(pool)
28219×
    .await?;
28319×
    touch(pool, project_id).await?;
28419×
    Ok(r.rows_affected() == 1)
28519×
}
286
287
/// Move a card to the bottom of another column of the same project.
2885×
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
2895×
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
2905×
        .bind(column_id)
2915×
        .bind(project_id)
2925×
        .fetch_one(pool)
2935×
        .await?;
2945×
    if ok == 0 {
2950×
        return Ok(());
2965×
    }
2975×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2985×
        .bind(column_id)
2995×
        .fetch_one(pool)
3005×
        .await?;
3015×
    sqlx::query("UPDATE project_cards SET moved_ms = CASE WHEN column_id = $1 THEN moved_ms ELSE $3 END, column_id = $1, position = $2 WHERE id = $4 AND project_id = $5")
3025×
        .bind(column_id)
3035×
        .bind(pos)
3045×
        .bind(now_ms())
3055×
        .bind(card_id)
3065×
        .bind(project_id)
3075×
        .execute(pool)
3085×
        .await?;
3095×
    touch(pool, project_id).await
3105×
}
311
312
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
313
/// (#289).
3141×
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
3151×
    let mut tx = pool.begin().await?;
3161×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
3171×
        .bind(column_id)
3181×
        .fetch_one(&mut *tx)
3191×
        .await?;
3201×
    let now = now_ms();
3211×
    for (i, id) in card_ids.iter().enumerate() {
3221×
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
3231×
            .bind(column_id)
3241×
            .bind(pos + i as i64)
3251×
            .bind(now)
3261×
            .bind(id)
3271×
            .bind(project_id)
3281×
            .execute(&mut *tx)
3291×
            .await?;
330
    }
3311×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3321×
    tx.commit().await
3331×
}
334
335
/// Take `card_ids` off the board, kept (#293); how many were on it.
3363×
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
3373×
    let mut tx = pool.begin().await?;
3383×
    let now = now_ms();
3393×
    let mut n = 0;
3403×
    for id in card_ids {
3413×
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
3423×
            .bind(now)
3433×
            .bind(id)
3443×
            .bind(project_id)
3453×
            .execute(&mut *tx)
3463×
            .await?
3473×
            .rows_affected();
348
    }
3493×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3503×
    tx.commit().await?;
3513×
    Ok(n)
3523×
}
353
354
/// Put an archived card back on the board, at the end of its column, as
355
/// if it had just joined it (so its column does not archive it again at
356
/// once); `false` when it was not archived.
3573×
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
3583×
    let mut tx = pool.begin().await?;
3593×
    let pos: i64 = sqlx::query_scalar(
3603×
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
3613×
    )
3623×
    .bind(card_id)
3633×
    .fetch_one(&mut *tx)
3643×
    .await?;
3653×
    let now = now_ms();
366
    // Back on the board, it no longer counts as done in a past iteration.
3673×
    let r = sqlx::query(
3683×
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
3693×
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
3703×
    )
3713×
    .bind(now)
3723×
    .bind(pos)
3733×
    .bind(card_id)
3743×
    .bind(project_id)
3753×
    .execute(&mut *tx)
3763×
    .await?;
3773×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3783×
    tx.commit().await?;
3793×
    Ok(r.rows_affected() == 1)
3803×
}
381
382
/// Whether `card` joined its column more than `days` ago, at `now`.
3838×
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
3848×
    card.moved_ms < now - days * DAY_MS
3858×
}
386
387
/// A board's iterations, newest first (#343).
38852×
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
38952×
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
39052×
        .bind(project_id)
39152×
        .fetch_all(pool)
39252×
        .await
39352×
}
394
395
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
3962×
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
3972×
    let mut tx = super::begin_write(pool).await?;
3982×
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
3992×
        .bind(project_id)
4002×
        .fetch_one(&mut *tx)
4012×
        .await?;
4022×
    if open > 0 {
4031×
        return Ok(None);
4041×
    }
4051×
    let it: Iteration =
4061×
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
4071×
            .bind(project_id)
4081×
            .bind(name)
4091×
            .bind(start_ms)
4101×
            .bind(end_ms)
4111×
            .fetch_one(&mut *tx)
4121×
            .await?;
4131×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
4141×
    tx.commit().await?;
4151×
    Ok(Some(it))
4162×
}
417
418
/// The column whose cards are done: the one that takes finished work, or
419
/// else the last.
42054×
pub fn done_column(columns: &[Column]) -> Option<i64> {
42154×
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
42254×
}
423
424
/// Close an open iteration: the cards on the board in `done` (its Done
425
/// column) are archived under it. How many, or `None` when it was not open.
4262×
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
4272×
    let mut tx = super::begin_write(pool).await?;
4282×
    let now = now_ms();
4292×
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
4302×
        .bind(now)
4312×
        .bind(iteration_id)
4322×
        .bind(project_id)
4332×
        .execute(&mut *tx)
4342×
        .await?;
4352×
    if closed.rows_affected() == 0 {
4361×
        return Ok(None);
4371×
    }
4381×
    let mut n = 0;
4391×
    if let Some(done) = done {
4401×
        n = sqlx::query(
4411×
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
4421×
        )
4431×
        .bind(now)
4441×
        .bind(iteration_id)
4451×
        .bind(project_id)
4461×
        .bind(done)
4471×
        .execute(&mut *tx)
4481×
        .await?
4491×
        .rows_affected();
4500×
    }
4511×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
4521×
    tx.commit().await?;
4531×
    Ok(Some(n))
4542×
}
455
456
/// When each card done in `it` reached the Done column `done`: those
457
/// archived under it, and while it is open those in `done` now or archived
458
/// from there during it.
45911×
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
46011×
    sqlx::query_scalar(
46111×
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
46211×
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
46311×
    )
46411×
    .bind(it.project_id)
46511×
    .bind(it.id)
46611×
    .bind(it.closed_ms.is_none())
46711×
    .bind(done.unwrap_or(-1))
46811×
    .bind(it.start_ms)
46911×
    .fetch_all(pool)
47011×
    .await
47111×
}
472
473
/// The burn-up of an iteration: cards done by the end of each of its days
474
/// so far, from `times` (when each reached Done; earlier than the start
475
/// counts on the first day). One entry per day up to `now` or its end.
47614×
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
47714×
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
47814×
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
479112×
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
48014×
}
481
482
/// An iteration's length in days.
48311×
pub fn iteration_days(it: &Iteration) -> i64 {
48411×
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
48511×
}
486
487
/// Archive a column's cards this many days after they join it; `None`
488
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
489
/// none.
4903×
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
4913×
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
4923×
        .bind(days)
4933×
        .bind(column_id)
4943×
        .bind(project_id)
4953×
        .execute(pool)
4963×
        .await?;
4973×
    touch(pool, project_id).await
4983×
}
499
500
/// Days after which a column with no setting of its own archives its
501
/// finished cards (#293): a board shows the last fortnight's done work.
502
pub const FINISHED_KEPT_DAYS: i64 = 14;
503
504
/// A column's `archive_after_days` that means never.
505
pub const NEVER: i64 = -1;
506
507
/// Archive, on every board, the cards their columns keep no longer (#293);
508
/// how many. A column that archives after N days: every card that joined it
509
/// longer ago. A column with no setting: finished cards (a closed issue, a
510
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
511
/// stops growing while open work and notes stay. [`NEVER`]: none.
512208×
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
513208×
    let set = sqlx::query(
514208×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
515208×
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
516208×
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
517208×
    )
518208×
    .bind(now)
519208×
    .bind(DAY_MS)
520208×
    .execute(pool)
521208×
    .await?;
522207×
    let finished = sqlx::query(
523207×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
524207×
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
525207×
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
526207×
               AND i.number = project_cards.number AND i.state = 'closed')) \
527207×
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
528207×
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
529207×
    )
530207×
    .bind(now)
531207×
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
532207×
    .execute(pool)
533207×
    .await?;
534207×
    Ok(set.rows_affected() + finished.rows_affected())
535207×
}
536
537
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
5380×
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
5390×
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
5400×
        .bind(card_id)
5410×
        .bind(project_id)
5420×
        .fetch_optional(pool)
5430×
        .await?
544
    else {
5450×
        return Ok(());
546
    };
5470×
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
5480×
        .bind(card.column_id)
5490×
        .fetch_all(pool)
5500×
        .await?;
5510×
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
5520×
    let j = i as i64 + by;
5530×
    if j < 0 || j as usize >= col.len() {
5540×
        return Ok(());
5550×
    }
5560×
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
5570×
    ids.swap(i, j as usize);
5580×
    for (pos, id) in ids.iter().enumerate() {
5590×
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
560
    }
5610×
    touch(pool, project_id).await
5620×
}
563
5643×
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
5653×
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
5663×
    touch(pool, project_id).await
5673×
}
568
569
/// The projects (and column names) an issue or proposal is on.
570
/// Make `column_id` the board's column for finished work, or none (#325).
5712×
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
5722×
    let mut tx = super::begin_write(pool).await?;
5732×
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
5742×
    if let Some(c) = column_id {
5751×
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
5761×
            .bind(c)
5771×
            .bind(project_id)
5781×
            .execute(&mut *tx)
5791×
            .await?;
5801×
    }
5812×
    tx.commit().await?;
5822×
    touch(pool, project_id).await
5832×
}
584
585
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
586
/// its cards move to their boards' column for finished work, where a board
587
/// has one (#325). Archived cards stay archived.
58837×
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
58937×
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
59037×
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
59137×
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
59237×
    )
59337×
    .bind(repo_id)
59437×
    .bind(number)
59537×
    .fetch_all(pool)
59637×
    .await?;
59737×
    for (card, project, column) in cards {
5983×
        let to: Option<i64> =
5993×
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
6003×
                .bind(project)
6013×
                .fetch_optional(pool)
6023×
                .await?;
6033×
        if let Some(to) = to.filter(|to| *to != column) {
6042×
            move_card(pool, project, card, to).await?;
6051×
        }
606
    }
60737×
    Ok(())
60837×
}
609
610106×
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
611106×
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
612106×
        "SELECT c.project_id, col.name, c.archived_ms FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note'",
613106×
    )
614106×
    .bind(repo_id)
615106×
    .bind(number)
616106×
    .fetch_all(pool)
617106×
    .await?;
618106×
    let mut out = Vec::new();
619106×
    for (pid, col, archived) in rows {
6205×
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
6215×
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
6225×
            out.push((p, col));
6235×
        }
624
    }
625106×
    Ok(out)
626106×
}