OXYGENHUB

Coverage / crates/hub/src/db/issues.rs 90.8%

305 of 336 lines run, 70 of 156 functions · run #536 on main · run missed

1
//! Issues, labels, milestones and their events (hub spec §8).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct IssueRow {
8
    pub id: i64,
9
    pub repo_id: i64,
10
    pub number: i64,
11
    pub title: String,
12
    pub body: String,
13
    pub author_id: Option<i64>,
14
    pub author: String,
15
    pub state: String,
16
    pub reason: String,
17
    pub locked: bool,
18
    pub pinned: bool,
19
    pub milestone_id: Option<i64>,
20
    pub created_ms: i64,
21
    pub updated_ms: i64,
22
    pub closed_ms: Option<i64>,
23
}
24
25
#[derive(Clone, Debug, FromRow)]
26
pub struct IssueComment {
27
    pub id: i64,
28
    pub issue_id: i64,
29
    pub author_id: Option<i64>,
30
    pub author: String,
31
    pub body: String,
32
    pub created_ms: i64,
33
    pub edited_ms: Option<i64>,
34
}
35
36
#[derive(Clone, Debug, FromRow)]
37
pub struct IssueEvent {
38
    pub id: i64,
39
    pub at_ms: i64,
40
    pub actor: String,
41
    pub event: String,
42
    pub detail: String,
43
}
44
45
#[derive(Clone, Debug, FromRow, PartialEq, Eq)]
46
pub struct Label {
47
    pub id: i64,
48
    pub repo_id: i64,
49
    pub name: String,
50
    pub colour: String,
51
    pub description: String,
52
}
53
54
#[derive(Clone, Debug, FromRow)]
55
pub struct Milestone {
56
    pub id: i64,
57
    pub repo_id: i64,
58
    pub title: String,
59
    pub description: String,
60
    pub due_ms: Option<i64>,
61
    pub state: String,
62
}
63
64
pub struct NewIssue<'a> {
65
    pub repo_id: i64,
66
    pub title: &'a str,
67
    pub body: &'a str,
68
    pub author_id: Option<i64>,
69
    pub author: &'a str,
70
}
71
7269×
pub async fn create(pool: &Pool, i: NewIssue<'_>) -> sqlx::Result<IssueRow> {
7369×
    let number = super::proposals::next_number(pool, i.repo_id).await?;
7469×
    let now = now_ms();
7569×
    sqlx::query_as("INSERT INTO issues (repo_id, number, title, body, author_id, author, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *")
7669×
        .bind(i.repo_id)
7769×
        .bind(number)
7869×
        .bind(i.title)
7969×
        .bind(i.body)
8069×
        .bind(i.author_id)
8169×
        .bind(i.author)
8269×
        .bind(now)
8369×
        .bind(now)
8469×
        .fetch_one(pool)
8569×
        .await
8669×
}
87
88130×
pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<IssueRow>> {
89130×
    sqlx::query_as("SELECT * FROM issues WHERE repo_id = $1 AND number = $2").bind(repo_id).bind(number).fetch_optional(pool).await
90130×
}
91
920×
pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<IssueRow>> {
930×
    sqlx::query_as("SELECT * FROM issues WHERE id = $1").bind(id).fetch_optional(pool).await
940×
}
95
9662×
pub async fn list(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<IssueRow>> {
9762×
    sqlx::query_as("SELECT * FROM issues WHERE repo_id = $1 ORDER BY pinned DESC, number DESC").bind(repo_id).fetch_all(pool).await
9862×
}
99
100
/// Numbers in use by issues and proposals, for cross references.
101411×
pub async fn numbers(pool: &Pool, repo_id: i64) -> sqlx::Result<(Vec<i64>, Vec<i64>)> {
102411×
    let issues: Vec<i64> = sqlx::query_scalar("SELECT number FROM issues WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await?;
103410×
    let proposals: Vec<i64> = sqlx::query_scalar("SELECT number FROM proposals WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await?;
104410×
    Ok((issues, proposals))
105410×
}
106
1070×
pub async fn assigned_to(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<IssueRow>> {
1080×
    sqlx::query_as("SELECT i.* FROM issues i JOIN issue_assignees a ON a.issue_id = i.id WHERE a.account_id = $1 AND i.state = 'open' ORDER BY i.updated_ms DESC")
1090×
        .bind(account_id)
1100×
        .fetch_all(pool)
1110×
        .await
1120×
}
113
1142×
pub async fn edit(pool: &Pool, id: i64, title: &str, body: &str) -> sqlx::Result<()> {
1152×
    sqlx::query("UPDATE issues SET title = $1, body = $2, updated_ms = $3 WHERE id = $4")
1162×
        .bind(title)
1172×
        .bind(body)
1182×
        .bind(now_ms())
1192×
        .bind(id)
1202×
        .execute(pool)
1212×
        .await?;
1222×
    Ok(())
1232×
}
124
12511×
pub async fn set_state(pool: &Pool, id: i64, state: &str, reason: &str) -> sqlx::Result<()> {
12611×
    let now = now_ms();
12711×
    let closed: Option<i64> = if state == "closed" { Some(now) } else { None };
12811×
    sqlx::query("UPDATE issues SET state = $1, reason = $2, closed_ms = $3, updated_ms = $4 WHERE id = $5")
12911×
        .bind(state)
13011×
        .bind(reason)
13111×
        .bind(closed)
13211×
        .bind(now)
13311×
        .bind(id)
13411×
        .execute(pool)
13511×
        .await?;
13611×
    if state == "closed" {
13711×
        finished(pool, "issues", id).await?;
1380×
    }
13911×
    Ok(())
14011×
}
141
142
/// Move the cards of finished issue or proposal `id` of `table` (#325).
14337×
pub(super) async fn finished(pool: &Pool, table: &str, id: i64) -> sqlx::Result<()> {
14437×
    let at: Option<(i64, i64)> =
14537×
        sqlx::query_as(&format!("SELECT repo_id, number FROM {table} WHERE id = $1")).bind(id).fetch_optional(pool).await?;
14637×
    match at {
14737×
        Some((repo, number)) => super::projects::finished(pool, repo, number).await,
1480×
        None => Ok(()),
149
    }
15037×
}
151
1521×
pub async fn set_flags(pool: &Pool, id: i64, locked: Option<bool>, pinned: Option<bool>) -> sqlx::Result<()> {
1531×
    if let Some(l) = locked {
1540×
        sqlx::query("UPDATE issues SET locked = $1 WHERE id = $2").bind(l).bind(id).execute(pool).await?;
1551×
    }
1561×
    if let Some(p) = pinned {
1571×
        sqlx::query("UPDATE issues SET pinned = $1 WHERE id = $2").bind(p).bind(id).execute(pool).await?;
1580×
    }
1591×
    Ok(())
1601×
}
161
1622×
pub async fn set_milestone(pool: &Pool, id: i64, milestone_id: Option<i64>) -> sqlx::Result<()> {
1632×
    sqlx::query("UPDATE issues SET milestone_id = $1, updated_ms = $2 WHERE id = $3")
1642×
        .bind(milestone_id)
1652×
        .bind(now_ms())
1662×
        .bind(id)
1672×
        .execute(pool)
1682×
        .await?;
1692×
    Ok(())
1702×
}
171
17214×
pub async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
17314×
    sqlx::query("UPDATE issues SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
17414×
    Ok(())
17514×
}
176
17770×
pub async fn comments(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<IssueComment>> {
17870×
    sqlx::query_as("SELECT * FROM issue_comments WHERE issue_id = $1 ORDER BY created_ms, id").bind(issue_id).fetch_all(pool).await
17970×
}
180
18110×
pub async fn add_comment(pool: &Pool, issue_id: i64, author_id: Option<i64>, author: &str, body: &str) -> sqlx::Result<IssueComment> {
18210×
    let row = sqlx::query_as(
18310×
        "INSERT INTO issue_comments (issue_id, author_id, author, body, created_ms) VALUES ($1, $2, $3, $4, $5) RETURNING *",
18410×
    )
18510×
    .bind(issue_id)
18610×
    .bind(author_id)
18710×
    .bind(author)
18810×
    .bind(body)
18910×
    .bind(now_ms())
19010×
    .fetch_one(pool)
19110×
    .await?;
19210×
    touch(pool, issue_id).await?;
19310×
    Ok(row)
19410×
}
195
1960×
pub async fn edit_comment(pool: &Pool, id: i64, author_id: i64, body: &str) -> sqlx::Result<bool> {
1970×
    let n = sqlx::query("UPDATE issue_comments SET body = $1, edited_ms = $2 WHERE id = $3 AND author_id = $4")
1980×
        .bind(body)
1990×
        .bind(now_ms())
2000×
        .bind(id)
2010×
        .bind(author_id)
2020×
        .execute(pool)
2030×
        .await?;
2040×
    Ok(n.rows_affected() > 0)
2050×
}
206
2070×
pub async fn delete_comment(pool: &Pool, id: i64) -> sqlx::Result<()> {
2080×
    sqlx::query("DELETE FROM issue_comments WHERE id = $1").bind(id).execute(pool).await?;
2090×
    Ok(())
2100×
}
211
21229×
pub async fn events(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<IssueEvent>> {
21329×
    sqlx::query_as("SELECT id, at_ms, actor, event, detail FROM issue_events WHERE issue_id = $1 ORDER BY at_ms, id")
21429×
        .bind(issue_id)
21529×
        .fetch_all(pool)
21629×
        .await
21729×
}
218
219
/// Whether change `hash` (its short form) already references it (#318):
220
/// a change is recorded once, whichever bookmarks it is pushed on.
2211×
pub async fn referenced_by(pool: &Pool, issue_id: i64, hash: &str) -> sqlx::Result<bool> {
2221×
    let n: i64 = sqlx::query_scalar(
2231×
        "SELECT COUNT(*) FROM issue_events WHERE issue_id = $1 AND event = 'referenced by change' AND (detail = $2 OR detail LIKE $3)",
2241×
    )
2251×
    .bind(issue_id)
2261×
    .bind(hash)
2271×
    .bind(format!("{hash} %"))
2281×
    .fetch_one(pool)
2291×
    .await?;
2301×
    Ok(n > 0)
2311×
}
232
23352×
pub async fn add_event(pool: &Pool, issue_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> {
23452×
    sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, $3, $4, $5)")
23552×
        .bind(issue_id)
23652×
        .bind(now_ms())
23752×
        .bind(actor)
23852×
        .bind(event)
23952×
        .bind(detail)
24052×
        .execute(pool)
24152×
        .await?;
24252×
    Ok(())
24352×
}
244
245
// ------------------------------------------------------------------ labels
246
247113×
pub async fn labels(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Label>> {
248113×
    sqlx::query_as("SELECT * FROM labels WHERE repo_id = $1 ORDER BY name").bind(repo_id).fetch_all(pool).await
249113×
}
250
25124×
pub async fn label_by_name(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Option<Label>> {
25224×
    sqlx::query_as("SELECT * FROM labels WHERE repo_id = $1 AND name = $2").bind(repo_id).bind(name).fetch_optional(pool).await
25324×
}
254
25523×
pub async fn upsert_label(pool: &Pool, repo_id: i64, name: &str, colour: &str, description: &str) -> sqlx::Result<Label> {
25623×
    sqlx::query_as("INSERT INTO labels (repo_id, name, colour, description) VALUES ($1, $2, $3, $4) ON CONFLICT (repo_id, name) DO UPDATE SET colour = excluded.colour, description = excluded.description RETURNING *")
25723×
        .bind(repo_id)
25823×
        .bind(name)
25923×
        .bind(colour)
26023×
        .bind(description)
26123×
        .fetch_one(pool)
26223×
        .await
26323×
}
264
2651×
pub async fn delete_label(pool: &Pool, id: i64) -> sqlx::Result<()> {
2661×
    sqlx::query("DELETE FROM labels WHERE id = $1").bind(id).execute(pool).await?;
2671×
    Ok(())
2681×
}
269
27068×
pub async fn issue_labels(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<Label>> {
27168×
    sqlx::query_as("SELECT l.* FROM labels l JOIN issue_labels il ON il.label_id = l.id WHERE il.issue_id = $1 ORDER BY l.name")
27268×
        .bind(issue_id)
27368×
        .fetch_all(pool)
27468×
        .await
27568×
}
276
27710×
pub async fn set_issue_label(pool: &Pool, issue_id: i64, label_id: i64, on: bool) -> sqlx::Result<()> {
27810×
    if on {
2798×
        sqlx::query("INSERT INTO issue_labels (issue_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
2808×
            .bind(issue_id)
2818×
            .bind(label_id)
2828×
            .execute(pool)
2838×
            .await?;
284
    } else {
2852×
        sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1 AND label_id = $2").bind(issue_id).bind(label_id).execute(pool).await?;
286
    }
28710×
    Ok(())
28810×
}
289
290
/// Labels of every issue of a repository, for the list page.
291
/// Every proposal's labels in a repository, for filtering the list.
29216×
pub async fn labels_by_proposal(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> {
29316×
    let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as(
29416×
        "SELECT pl.proposal_id, l.id, l.repo_id, l.name, l.colour, l.description FROM proposal_labels pl JOIN labels l ON l.id = pl.label_id JOIN proposals p ON p.id = pl.proposal_id WHERE p.repo_id = $1 ORDER BY l.name",
29516×
    )
29616×
    .bind(repo_id)
29716×
    .fetch_all(pool)
29816×
    .await?;
29916×
    Ok(rows
30016×
        .into_iter()
30116×
        .map(|(proposal_id, id, repo_id, name, colour, description)| (proposal_id, Label { id, repo_id, name, colour, description }))
30216×
        .collect())
30316×
}
304
30555×
pub async fn labels_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> {
30655×
    let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as(
30755×
        "SELECT il.issue_id, l.id, l.repo_id, l.name, l.colour, l.description FROM issue_labels il JOIN labels l ON l.id = il.label_id JOIN issues i ON i.id = il.issue_id WHERE i.repo_id = $1 ORDER BY l.name",
30855×
    )
30955×
    .bind(repo_id)
31055×
    .fetch_all(pool)
31155×
    .await?;
31255×
    Ok(rows
31355×
        .into_iter()
31455×
        .map(|(issue_id, id, repo_id, name, colour, description)| (issue_id, Label { id, repo_id, name, colour, description }))
31555×
        .collect())
31655×
}
317
318121×
pub async fn proposal_labels(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<Label>> {
319121×
    sqlx::query_as("SELECT l.* FROM labels l JOIN proposal_labels pl ON pl.label_id = l.id WHERE pl.proposal_id = $1 ORDER BY l.name")
320121×
        .bind(proposal_id)
321121×
        .fetch_all(pool)
322121×
        .await
323121×
}
324
32514×
pub async fn set_proposal_label(pool: &Pool, proposal_id: i64, label_id: i64, on: bool) -> sqlx::Result<()> {
32614×
    if on {
32713×
        sqlx::query("INSERT INTO proposal_labels (proposal_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
32813×
            .bind(proposal_id)
32913×
            .bind(label_id)
33013×
            .execute(pool)
33113×
            .await?;
332
    } else {
3331×
        sqlx::query("DELETE FROM proposal_labels WHERE proposal_id = $1 AND label_id = $2")
3341×
            .bind(proposal_id)
3351×
            .bind(label_id)
3361×
            .execute(pool)
3371×
            .await?;
338
    }
33914×
    Ok(())
34014×
}
341
342
// --------------------------------------------------------------- assignees
343
34474×
pub async fn assignees(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<String>> {
34574×
    sqlx::query_scalar(
34674×
        "SELECT a.username FROM accounts a JOIN issue_assignees ia ON ia.account_id = a.id WHERE ia.issue_id = $1 ORDER BY a.username",
34774×
    )
34874×
    .bind(issue_id)
34974×
    .fetch_all(pool)
35074×
    .await
35174×
}
352
35311×
pub async fn set_assignee(pool: &Pool, issue_id: i64, account_id: i64, on: bool) -> sqlx::Result<()> {
35411×
    if on {
3558×
        sqlx::query("INSERT INTO issue_assignees (issue_id, account_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
3568×
            .bind(issue_id)
3578×
            .bind(account_id)
3588×
            .execute(pool)
3598×
            .await?;
360
    } else {
3613×
        sqlx::query("DELETE FROM issue_assignees WHERE issue_id = $1 AND account_id = $2")
3623×
            .bind(issue_id)
3633×
            .bind(account_id)
3643×
            .execute(pool)
3653×
            .await?;
366
    }
36711×
    Ok(())
36811×
}
369
370
/// Assignees of every issue of a repository, for the list page.
37155×
pub async fn assignees_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, String)>> {
37255×
    sqlx::query_as("SELECT ia.issue_id, a.username FROM issue_assignees ia JOIN accounts a ON a.id = ia.account_id JOIN issues i ON i.id = ia.issue_id WHERE i.repo_id = $1")
37355×
        .bind(repo_id)
37455×
        .fetch_all(pool)
37555×
        .await
37655×
}
377
378
// -------------------------------------------------------------- milestones
379
38043×
pub async fn milestones(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Milestone>> {
38143×
    sqlx::query_as("SELECT * FROM milestones WHERE repo_id = $1 ORDER BY state, due_ms, title").bind(repo_id).fetch_all(pool).await
38243×
}
383
38411×
pub async fn milestone(pool: &Pool, id: i64) -> sqlx::Result<Option<Milestone>> {
38511×
    sqlx::query_as("SELECT * FROM milestones WHERE id = $1").bind(id).fetch_optional(pool).await
38611×
}
387
3883×
pub async fn upsert_milestone(pool: &Pool, repo_id: i64, title: &str, description: &str, due_ms: Option<i64>) -> sqlx::Result<Milestone> {
3893×
    sqlx::query_as("INSERT INTO milestones (repo_id, title, description, due_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (repo_id, title) DO UPDATE SET description = excluded.description, due_ms = excluded.due_ms RETURNING *")
3903×
        .bind(repo_id)
3913×
        .bind(title)
3923×
        .bind(description)
3933×
        .bind(due_ms)
3943×
        .fetch_one(pool)
3953×
        .await
3963×
}
397
398
/// Change a milestone's title, description and due date.
3991×
pub async fn update_milestone(pool: &Pool, id: i64, title: &str, description: &str, due_ms: Option<i64>) -> sqlx::Result<()> {
4001×
    sqlx::query("UPDATE milestones SET title = $1, description = $2, due_ms = $3 WHERE id = $4")
4011×
        .bind(title)
4021×
        .bind(description)
4031×
        .bind(due_ms)
4041×
        .bind(id)
4051×
        .execute(pool)
4061×
        .await?;
4071×
    Ok(())
4081×
}
409
4101×
pub async fn set_milestone_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
4111×
    sqlx::query("UPDATE milestones SET state = $1 WHERE id = $2").bind(state).bind(id).execute(pool).await?;
4121×
    Ok(())
4131×
}
414
415
/// `(open, closed)` issue counts per milestone.
4165×
pub async fn milestone_counts(pool: &Pool, milestone_id: i64) -> sqlx::Result<(i64, i64)> {
4175×
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE milestone_id = $1 AND state = 'open'")
4185×
        .bind(milestone_id)
4195×
        .fetch_one(pool)
4205×
        .await?;
4215×
    let closed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE milestone_id = $1 AND state = 'closed'")
4225×
        .bind(milestone_id)
4235×
        .fetch_one(pool)
4245×
        .await?;
4255×
    Ok((open, closed))
4265×
}