OXYGENHUB

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

554 of 613 lines run, 80 of 176 functions · run #541 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
    /// Work in progress past this many cards turns its header amber (#361).
31
    #[sqlx(default)]
32
    pub wip_limit: Option<i64>,
33
}
34
35
#[derive(Clone, Debug, FromRow)]
36
pub struct Card {
37
    pub id: i64,
38
    pub project_id: i64,
39
    pub column_id: i64,
40
    pub position: i64,
41
    pub kind: String,
42
    pub repo_id: Option<i64>,
43
    pub number: Option<i64>,
44
    pub note: String,
45
    pub added_by: String,
46
    pub created_ms: i64,
47
    /// Off the board since then (#293): kept, and restorable.
48
    pub archived_ms: Option<i64>,
49
    /// When it joined its column.
50
    pub moved_ms: i64,
51
    /// The iteration it was archived under when that closed (#343).
52
    pub iteration_id: Option<i64>,
53
}
54
55
/// A named span of a board's days (#343). A board has one open at most;
56
/// closing it archives its Done column's cards under it.
57
#[derive(Clone, Debug, FromRow)]
58
pub struct Iteration {
59
    pub id: i64,
60
    pub project_id: i64,
61
    pub name: String,
62
    pub start_ms: i64,
63
    /// The end, exclusive: the midnight after its last day.
64
    pub end_ms: i64,
65
    pub closed_ms: Option<i64>,
66
}
67
68
/// A day, for the archive's ages.
69
const DAY_MS: i64 = 86_400_000;
70
71
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
72
7311×
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
7411×
    let mut tx = super::begin_write(pool).await?;
7511×
    let number: i64 =
7611×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
7711×
    let now = now_ms();
7811×
    let p: Project = sqlx::query_as(
7911×
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
8011×
    )
8111×
    .bind(owner)
8211×
    .bind(number)
8311×
    .bind(title)
8411×
    .bind(description)
8511×
    .bind(public)
8611×
    .bind(now)
8711×
    .bind(now)
8811×
    .fetch_one(&mut *tx)
8911×
    .await?;
90
    // A board here now: an old address of a transferred one gives way (#125).
9111×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
9233×
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
9333×
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
9433×
            .bind(p.id)
9533×
            .bind(name)
9633×
            .bind(i as i64)
9733×
            .bind(*name == "Done")
9833×
            .execute(&mut *tx)
9933×
            .await?;
100
    }
10111×
    tx.commit().await?;
10211×
    Ok(p)
10311×
}
104
10576×
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
10676×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
10776×
}
108
109
/// Move project `id` to owner `to` (#125): it takes the next number there,
110
/// with its columns, cards and privacy, and its old address redirects to it.
1111×
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
1121×
    let mut tx = super::begin_write(pool).await?;
1131×
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
1141×
    let number: i64 =
1151×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
1161×
    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")
1171×
        .bind(&old.owner)
1181×
        .bind(old.number)
1191×
        .bind(id)
1201×
        .execute(&mut *tx)
1211×
        .await?;
1221×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
1231×
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
1241×
        .bind(to)
1251×
        .bind(number)
1261×
        .bind(now_ms())
1271×
        .bind(id)
1281×
        .fetch_one(&mut *tx)
1291×
        .await?;
1301×
    tx.commit().await?;
1311×
    Ok(moved)
1321×
}
133
134
/// The project an old address (`owner`, `number`) now redirects to, after
135
/// a transfer (#125).
1362×
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
1372×
    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")
1382×
        .bind(owner)
1392×
        .bind(number)
1402×
        .fetch_optional(pool)
1412×
        .await
1422×
}
143
144217×
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
145217×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
146217×
}
147
1486×
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
1496×
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
1506×
        .bind(title)
1516×
        .bind(description)
1526×
        .bind(public)
1536×
        .bind(closed)
1546×
        .bind(now_ms())
1556×
        .bind(id)
1566×
        .execute(pool)
1576×
        .await?;
1586×
    Ok(())
1596×
}
160
1611×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
1621×
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
1631×
    Ok(())
1641×
}
165
16643×
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
16743×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
16843×
    Ok(())
16943×
}
170
171163×
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
172163×
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
173163×
}
174
17591×
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
17691×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
17791×
}
178
1791×
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
1801×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
1811×
        .bind(project_id)
1821×
        .fetch_one(pool)
1831×
        .await?;
1841×
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
1851×
        .bind(project_id)
1861×
        .bind(name)
1871×
        .bind(pos)
1881×
        .execute(pool)
1891×
        .await?;
1901×
    touch(pool, project_id).await
1911×
}
192
1931×
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
1941×
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
1951×
        .bind(name)
1961×
        .bind(column_id)
1971×
        .bind(project_id)
1981×
        .execute(pool)
1991×
        .await?;
2001×
    touch(pool, project_id).await
2011×
}
202
203
/// Swap a column with its neighbour (`-1` left, `1` right).
2040×
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
2050×
    let cols = columns(pool, project_id).await?;
2060×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
2070×
    let j = i as i64 + by;
2080×
    if j < 0 || j as usize >= cols.len() {
2090×
        return Ok(());
2100×
    }
2110×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
2120×
    ids.swap(i, j as usize);
2130×
    for (pos, id) in ids.iter().enumerate() {
2140×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
215
    }
2160×
    touch(pool, project_id).await
2170×
}
218
219
/// Move a column to `position` (0 the leftmost; past the end the rightmost).
2202×
pub async fn place_column(pool: &Pool, project_id: i64, column_id: i64, position: i64) -> sqlx::Result<()> {
2212×
    let cols = columns(pool, project_id).await?;
2225×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
2232×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
2242×
    let id = ids.remove(i);
2252×
    ids.insert((position.max(0) as usize).min(ids.len()), id);
2268×
    for (pos, id) in ids.iter().enumerate() {
2278×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
228
    }
2292×
    touch(pool, project_id).await
2302×
}
231
232
/// Remove a column; its cards go to the first remaining column. The last column stays.
2334×
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
2344×
    let cols = columns(pool, project_id).await?;
2354×
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
2368×
    if !cols.iter().any(|c| c.id == column_id) {
2370×
        return Ok(false);
2383×
    }
2393×
    let mut tx = super::begin_write(pool).await?;
2403×
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2413×
        .bind(to.id)
2423×
        .fetch_one(&mut *tx)
2433×
        .await?;
2443×
    log_moves(&mut *tx, Some(to.id), now_ms(), "column_id = $1 AND archived_ms IS NULL", &[column_id]).await?;
2453×
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
2463×
        .bind(to.id)
2473×
        .bind(base)
2483×
        .bind(column_id)
2493×
        .execute(&mut *tx)
2503×
        .await?;
2513×
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
2523×
    tx.commit().await?;
2533×
    touch(pool, project_id).await?;
2543×
    Ok(true)
2554×
}
256
257
/// Record, for every card `select` picks (`SELECT project_id, id FROM
258
/// project_cards WHERE …`, bound from `$1` on), that it moved to `column`
259
/// (`None`: off the board) at `at` (#357). Called before the update that
260
/// moves them, with the same conditions.
261447×
async fn log_moves<'e, E: sqlx::Executor<'e, Database = super::Db>>(
262447×
    ex: E,
263447×
    column: Option<i64>,
264447×
    at: i64,
265447×
    select: &str,
266447×
    binds: &[i64],
267447×
) -> sqlx::Result<()> {
268447×
    let sql = format!(
269
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, {}, {at} FROM project_cards WHERE {select}",
270447×
        match column {
27124×
            Some(c) => c.to_string(),
272423×
            None => "NULL".to_string(),
273
        }
274
    );
275447×
    let mut q = sqlx::query(&sql);
276674×
    for b in binds {
277674×
        q = q.bind(*b);
278674×
    }
279447×
    q.execute(ex).await?;
280447×
    Ok(())
281447×
}
282
283
/// A board's card moves since `since`, oldest first: (card, column or
284
/// `None` off the board, when).
2850×
pub async fn moves(pool: &Pool, project_id: i64, since: i64) -> sqlx::Result<Vec<(i64, Option<i64>, i64)>> {
2860×
    sqlx::query_as("SELECT card_id, column_id, at_ms FROM project_card_moves WHERE project_id = $1 AND at_ms >= $2 ORDER BY at_ms, id")
2870×
        .bind(project_id)
2880×
        .bind(since)
2890×
        .fetch_all(pool)
2900×
        .await
2910×
}
292
293
pub struct NewCard<'a> {
294
    pub column_id: i64,
295
    pub kind: &'a str,
296
    pub repo_id: Option<i64>,
297
    pub number: Option<i64>,
298
    pub note: &'a str,
299
    pub added_by: &'a str,
300
}
301
302
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
303
/// proposal is already on the project.
30419×
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
30519×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
30619×
        .bind(c.column_id)
30719×
        .fetch_one(pool)
30819×
        .await?;
30919×
    let now = now_ms();
31019×
    let id: Option<i64> = sqlx::query_scalar(
31119×
        "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 RETURNING id",
31219×
    )
31319×
    .bind(project_id)
31419×
    .bind(c.column_id)
31519×
    .bind(pos)
31619×
    .bind(c.kind)
31719×
    .bind(c.repo_id)
31819×
    .bind(c.number)
31919×
    .bind(c.note)
32019×
    .bind(c.added_by)
32119×
    .bind(now)
32219×
    .fetch_optional(pool)
32319×
    .await?;
32419×
    if let Some(id) = id {
32515×
        log_moves(pool, Some(c.column_id), now, "id = $1", &[id]).await?;
3264×
    }
32719×
    touch(pool, project_id).await?;
32819×
    Ok(id.is_some())
32919×
}
330
331
/// Move a card to the bottom of another column of the same project.
3325×
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
3335×
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
3345×
        .bind(column_id)
3355×
        .bind(project_id)
3365×
        .fetch_one(pool)
3375×
        .await?;
3385×
    if ok == 0 {
3390×
        return Ok(());
3405×
    }
3415×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
3425×
        .bind(column_id)
3435×
        .fetch_one(pool)
3445×
        .await?;
3455×
    let now = now_ms();
3465×
    log_moves(
3475×
        pool,
3485×
        Some(column_id),
3495×
        now,
3505×
        "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
3515×
        &[card_id, project_id, column_id],
3525×
    )
3535×
    .await?;
3545×
    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")
3555×
        .bind(column_id)
3565×
        .bind(pos)
3575×
        .bind(now)
3585×
        .bind(card_id)
3595×
        .bind(project_id)
3605×
        .execute(pool)
3615×
        .await?;
3625×
    touch(pool, project_id).await
3635×
}
364
365
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
366
/// (#289).
3671×
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
3681×
    let mut tx = pool.begin().await?;
3691×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
3701×
        .bind(column_id)
3711×
        .fetch_one(&mut *tx)
3721×
        .await?;
3731×
    let now = now_ms();
3741×
    for (i, id) in card_ids.iter().enumerate() {
3751×
        log_moves(
3761×
            &mut *tx,
3771×
            Some(column_id),
3781×
            now,
3791×
            "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
3801×
            &[*id, project_id, column_id],
3811×
        )
3821×
        .await?;
3831×
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
3841×
            .bind(column_id)
3851×
            .bind(pos + i as i64)
3861×
            .bind(now)
3871×
            .bind(id)
3881×
            .bind(project_id)
3891×
            .execute(&mut *tx)
3901×
            .await?;
391
    }
3921×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3931×
    tx.commit().await
3941×
}
395
396
/// Take `card_ids` off the board, kept (#293); how many were on it.
3973×
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
3983×
    let mut tx = pool.begin().await?;
3993×
    let now = now_ms();
4003×
    let mut n = 0;
4013×
    for id in card_ids {
4023×
        log_moves(&mut *tx, None, now, "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[*id, project_id]).await?;
4033×
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
4043×
            .bind(now)
4053×
            .bind(id)
4063×
            .bind(project_id)
4073×
            .execute(&mut *tx)
4083×
            .await?
4093×
            .rows_affected();
410
    }
4113×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
4123×
    tx.commit().await?;
4133×
    Ok(n)
4143×
}
415
416
/// Put an archived card back on the board, at the end of its column, as
417
/// if it had just joined it (so its column does not archive it again at
418
/// once); `false` when it was not archived.
4193×
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
4203×
    let mut tx = pool.begin().await?;
4213×
    let pos: i64 = sqlx::query_scalar(
4223×
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
4233×
    )
4243×
    .bind(card_id)
4253×
    .fetch_one(&mut *tx)
4263×
    .await?;
4273×
    let now = now_ms();
4283×
    sqlx::query(&format!(
4293×
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, column_id, {now} FROM project_cards \
4303×
         WHERE id = $1 AND project_id = $2 AND archived_ms IS NOT NULL"
4313×
    ))
4323×
    .bind(card_id)
4333×
    .bind(project_id)
4343×
    .execute(&mut *tx)
4353×
    .await?;
436
    // Back on the board, it no longer counts as done in a past iteration.
4373×
    let r = sqlx::query(
4383×
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
4393×
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
4403×
    )
4413×
    .bind(now)
4423×
    .bind(pos)
4433×
    .bind(card_id)
4443×
    .bind(project_id)
4453×
    .execute(&mut *tx)
4463×
    .await?;
4473×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
4483×
    tx.commit().await?;
4493×
    Ok(r.rows_affected() == 1)
4503×
}
451
452
/// Whether `card` joined its column more than `days` ago, at `now`.
4538×
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
4548×
    card.moved_ms < now - days * DAY_MS
4558×
}
456
457
/// A board's iterations, newest first (#343).
45857×
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
45957×
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
46057×
        .bind(project_id)
46157×
        .fetch_all(pool)
46257×
        .await
46357×
}
464
465
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
4662×
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
4672×
    let mut tx = super::begin_write(pool).await?;
4682×
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
4692×
        .bind(project_id)
4702×
        .fetch_one(&mut *tx)
4712×
        .await?;
4722×
    if open > 0 {
4731×
        return Ok(None);
4741×
    }
4751×
    let it: Iteration =
4761×
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
4771×
            .bind(project_id)
4781×
            .bind(name)
4791×
            .bind(start_ms)
4801×
            .bind(end_ms)
4811×
            .fetch_one(&mut *tx)
4821×
            .await?;
4831×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
4841×
    tx.commit().await?;
4851×
    Ok(Some(it))
4862×
}
487
488
/// The column whose cards are done: the one that takes finished work, or
489
/// else the last.
49059×
pub fn done_column(columns: &[Column]) -> Option<i64> {
49159×
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
49259×
}
493
494
/// Close an open iteration: the cards on the board in `done` (its Done
495
/// column) are archived under it. How many, or `None` when it was not open.
4962×
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
4972×
    let mut tx = super::begin_write(pool).await?;
4982×
    let now = now_ms();
4992×
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
5002×
        .bind(now)
5012×
        .bind(iteration_id)
5022×
        .bind(project_id)
5032×
        .execute(&mut *tx)
5042×
        .await?;
5052×
    if closed.rows_affected() == 0 {
5061×
        return Ok(None);
5071×
    }
5081×
    let mut n = 0;
5091×
    if let Some(done) = done {
5101×
        log_moves(&mut *tx, None, now, "project_id = $1 AND column_id = $2 AND archived_ms IS NULL", &[project_id, done]).await?;
5111×
        n = sqlx::query(
5121×
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
5131×
        )
5141×
        .bind(now)
5151×
        .bind(iteration_id)
5161×
        .bind(project_id)
5171×
        .bind(done)
5181×
        .execute(&mut *tx)
5191×
        .await?
5201×
        .rows_affected();
5210×
    }
5221×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
5231×
    tx.commit().await?;
5241×
    Ok(Some(n))
5252×
}
526
527
/// When each card done in `it` reached the Done column `done`: those
528
/// archived under it, and while it is open those in `done` now or archived
529
/// from there during it.
53011×
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
53111×
    sqlx::query_scalar(
53211×
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
53311×
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
53411×
    )
53511×
    .bind(it.project_id)
53611×
    .bind(it.id)
53711×
    .bind(it.closed_ms.is_none())
53811×
    .bind(done.unwrap_or(-1))
53911×
    .bind(it.start_ms)
54011×
    .fetch_all(pool)
54111×
    .await
54211×
}
543
544
/// The burn-up of an iteration: cards done by the end of each of its days
545
/// so far, from `times` (when each reached Done; earlier than the start
546
/// counts on the first day). One entry per day up to `now` or its end.
54714×
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
54814×
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
54914×
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
550112×
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
55114×
}
552
553
/// An iteration's length in days.
55411×
pub fn iteration_days(it: &Iteration) -> i64 {
55511×
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
55611×
}
557
558
/// Archive a column's cards this many days after they join it; `None`
559
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
560
/// none.
5613×
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
5623×
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
5633×
        .bind(days)
5643×
        .bind(column_id)
5653×
        .bind(project_id)
5663×
        .execute(pool)
5673×
        .await?;
5683×
    touch(pool, project_id).await
5693×
}
570
571
/// Days after which a column with no setting of its own archives its
572
/// finished cards (#293): a board shows the last fortnight's done work.
573
pub const FINISHED_KEPT_DAYS: i64 = 14;
574
575
/// The finished cards due for archiving: in a column with no setting of
576
/// its own, joined before `$1`, and their issue closed or proposal closed
577
/// or landed.
578
const FINISHED_DUE: &str = "archived_ms IS NULL AND moved_ms < $1 \
579
     AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
580
     AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
581
           AND i.number = project_cards.number AND i.state = 'closed')) \
582
       OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
583
           AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))";
584
585
/// A column's `archive_after_days` that means never.
586
pub const NEVER: i64 = -1;
587
588
/// Archive, on every board, the cards their columns keep no longer (#293);
589
/// how many. A column that archives after N days: every card that joined it
590
/// longer ago. A column with no setting: finished cards (a closed issue, a
591
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
592
/// stops growing while open work and notes stay. [`NEVER`]: none.
593208×
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
594208×
    log_moves(
595208×
        pool,
596208×
        None,
597208×
        now,
598208×
        "archived_ms IS NULL AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id \
599208×
         AND c.archive_after_days >= 0 AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
600208×
        &[now, DAY_MS],
601208×
    )
602208×
    .await?;
603208×
    let set = sqlx::query(
604208×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
605208×
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
606208×
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
607208×
    )
608208×
    .bind(now)
609208×
    .bind(DAY_MS)
610208×
    .execute(pool)
611208×
    .await?;
612208×
    log_moves(pool, None, now, FINISHED_DUE, &[now - FINISHED_KEPT_DAYS * DAY_MS]).await?;
613208×
    let finished = sqlx::query(&format!("UPDATE project_cards SET archived_ms = $2 WHERE {FINISHED_DUE}"))
614208×
        .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
615208×
        .bind(now)
616208×
        .execute(pool)
617208×
        .await?;
618207×
    Ok(set.rows_affected() + finished.rows_affected())
619207×
}
620
621
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
6220×
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
6230×
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
6240×
        .bind(card_id)
6250×
        .bind(project_id)
6260×
        .fetch_optional(pool)
6270×
        .await?
628
    else {
6290×
        return Ok(());
630
    };
6310×
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
6320×
        .bind(card.column_id)
6330×
        .fetch_all(pool)
6340×
        .await?;
6350×
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
6360×
    let j = i as i64 + by;
6370×
    if j < 0 || j as usize >= col.len() {
6380×
        return Ok(());
6390×
    }
6400×
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
6410×
    ids.swap(i, j as usize);
6420×
    for (pos, id) in ids.iter().enumerate() {
6430×
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
644
    }
6450×
    touch(pool, project_id).await
6460×
}
647
6483×
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
6493×
    log_moves(pool, None, now_ms(), "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[card_id, project_id]).await?;
6503×
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
6513×
    touch(pool, project_id).await
6523×
}
653
654
/// The projects (and column names) an issue or proposal is on.
655
/// Make `column_id` the board's column for finished work, or none (#325).
6562×
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
6572×
    let mut tx = super::begin_write(pool).await?;
6582×
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
6592×
    if let Some(c) = column_id {
6601×
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
6611×
            .bind(c)
6621×
            .bind(project_id)
6631×
            .execute(&mut *tx)
6641×
            .await?;
6651×
    }
6662×
    tx.commit().await?;
6672×
    touch(pool, project_id).await
6682×
}
669
670
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
671
/// its cards move to their boards' column for finished work, where a board
672
/// has one (#325). Archived cards stay archived.
67337×
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
67437×
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
67537×
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
67637×
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
67737×
    )
67837×
    .bind(repo_id)
67937×
    .bind(number)
68037×
    .fetch_all(pool)
68137×
    .await?;
68237×
    for (card, project, column) in cards {
6833×
        let to: Option<i64> =
6843×
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
6853×
                .bind(project)
6863×
                .fetch_optional(pool)
6873×
                .await?;
6883×
        if let Some(to) = to.filter(|to| *to != column) {
6892×
            move_card(pool, project, card, to).await?;
6901×
        }
691
    }
69237×
    Ok(())
69337×
}
694
695106×
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
696106×
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
697106×
        "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'",
698106×
    )
699106×
    .bind(repo_id)
700106×
    .bind(number)
701106×
    .fetch_all(pool)
702106×
    .await?;
703106×
    let mut out = Vec::new();
704106×
    for (pid, col, archived) in rows {
7055×
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
7065×
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
7075×
            out.push((p, col));
7085×
        }
709
    }
710106×
    Ok(out)
711106×
}
712
713
/// A board's saved view (#361): a layout with its filter, lanes and sort.
714
#[derive(Clone, Debug, FromRow)]
715
pub struct View {
716
    pub id: i64,
717
    pub project_id: i64,
718
    pub name: String,
719
    /// `board` or `table`.
720
    pub layout: String,
721
    pub q: String,
722
    /// Swimlanes (board) or groups (table): `repo`, `assignee`, `label`,
723
    /// `milestone`, or empty.
724
    pub lane: String,
725
    /// The table's order: `title`, `reference`, `state`, `column`, `age`,
726
    /// a `-` before it for the reverse; empty is the board's order.
727
    pub sort: String,
728
    pub position: i64,
729
    pub created_ms: i64,
730
}
731
73270×
pub async fn views(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<View>> {
73370×
    sqlx::query_as("SELECT * FROM project_views WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
73470×
}
735
736
/// Save a view under `name`, replacing the one of that name.
7371×
pub async fn save_view(pool: &Pool, project_id: i64, name: &str, layout: &str, q: &str, lane: &str, sort: &str) -> sqlx::Result<View> {
7381×
    let mut tx = super::begin_write(pool).await?;
7391×
    let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM project_views WHERE project_id = $1 AND name = $2")
7401×
        .bind(project_id)
7411×
        .bind(name)
7421×
        .fetch_optional(&mut *tx)
7431×
        .await?;
7441×
    let v: View = match existing {
7450×
        Some(id) => {
7460×
            sqlx::query_as("UPDATE project_views SET layout = $1, q = $2, lane = $3, sort = $4 WHERE id = $5 RETURNING *")
7470×
                .bind(layout)
7480×
                .bind(q)
7490×
                .bind(lane)
7500×
                .bind(sort)
7510×
                .bind(id)
7520×
                .fetch_one(&mut *tx)
7530×
                .await?
754
        }
755
        None => {
7561×
            let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_views WHERE project_id = $1")
7571×
                .bind(project_id)
7581×
                .fetch_one(&mut *tx)
7591×
                .await?;
7601×
            sqlx::query_as(
7611×
                "INSERT INTO project_views (project_id, name, layout, q, lane, sort, position, created_ms) \
7621×
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
7631×
            )
7641×
            .bind(project_id)
7651×
            .bind(name)
7661×
            .bind(layout)
7671×
            .bind(q)
7681×
            .bind(lane)
7691×
            .bind(sort)
7701×
            .bind(pos)
7711×
            .bind(now_ms())
7721×
            .fetch_one(&mut *tx)
7731×
            .await?
774
        }
775
    };
7761×
    tx.commit().await?;
7771×
    touch(pool, project_id).await?;
7781×
    Ok(v)
7791×
}
780
7811×
pub async fn delete_view(pool: &Pool, project_id: i64, view_id: i64) -> sqlx::Result<bool> {
7821×
    let r = sqlx::query("DELETE FROM project_views WHERE id = $1 AND project_id = $2").bind(view_id).bind(project_id).execute(pool).await?;
7831×
    touch(pool, project_id).await?;
7841×
    Ok(r.rows_affected() == 1)
7851×
}
786
787
/// A column's work-in-progress limit (#361); `None` for none.
7882×
pub async fn set_wip_limit(pool: &Pool, project_id: i64, column_id: i64, limit: Option<i64>) -> sqlx::Result<()> {
7892×
    sqlx::query("UPDATE project_columns SET wip_limit = $1 WHERE id = $2 AND project_id = $3")
7902×
        .bind(limit)
7912×
        .bind(column_id)
7922×
        .bind(project_id)
7932×
        .execute(pool)
7942×
        .await?;
7952×
    touch(pool, project_id).await
7962×
}