OXYGENHUB

Coverage / crates/hub/src/db/proposals.rs 95.1%

309 of 325 lines run, 56 of 124 functions · run #538 on main · run missed

1
//! The `proposals`, `proposal_comments`, `proposal_reviews` and
2
//! `proposal_events` tables, and the per-repository number space.
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, Default, FromRow)]
8
pub struct ProposalRow {
9
    pub id: i64,
10
    pub repo_id: i64,
11
    pub number: i64,
12
    pub title: String,
13
    pub body: String,
14
    pub author_id: Option<i64>,
15
    pub author: String,
16
    pub state: String,
17
    pub target: String,
18
    pub source_kind: String,
19
    pub source_repo_id: Option<i64>,
20
    pub source_bookmark: String,
21
    pub source_changes: String,
22
    pub projected_state: String,
23
    pub projected_heads: String,
24
    pub projected_target: String,
25
    pub projected_changes: String,
26
    pub conflicted: bool,
27
    pub landed_state: String,
28
    pub landed_by: String,
29
    pub landed_via: String,
30
    pub landed_ms: i64,
31
    pub created_ms: i64,
32
    pub updated_ms: i64,
33
    /// Who set it to land when its checks pass (#261); empty when off.
34
    #[sqlx(default)]
35
    pub auto_land_by: String,
36
    #[sqlx(default)]
37
    pub auto_land_ms: Option<i64>,
38
}
39
40
impl ProposalRow {
4111×
    pub fn projected_heads(&self) -> Vec<String> {
4214×
        self.projected_heads.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
4311×
    }
44
4541×
    pub fn projected_changes(&self) -> Vec<String> {
4641×
        self.projected_changes.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
4741×
    }
48
490×
    pub fn source_changes(&self) -> Vec<String> {
500×
        self.source_changes.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
510×
    }
52
531235×
    pub fn is_open(&self) -> bool {
541235×
        self.state == "open" || self.state == "draft"
551235×
    }
56
}
57
58
#[derive(Clone, Debug, FromRow)]
59
pub struct CommentRow {
60
    pub id: i64,
61
    pub proposal_id: i64,
62
    pub author_id: Option<i64>,
63
    pub author: String,
64
    pub body: String,
65
    pub path: String,
66
    pub anchor_change: String,
67
    pub anchor_index: i64,
68
    pub resolved: bool,
69
    pub created_ms: i64,
70
    /// The AI assistant that wrote it for the author (migration 0066),
71
    /// empty for the author's own comment.
72
    #[sqlx(default)]
73
    pub via: String,
74
}
75
76
#[derive(Clone, Debug, FromRow)]
77
pub struct ReviewRow {
78
    pub id: i64,
79
    pub proposal_id: i64,
80
    /// `None` once the author's account was deleted (its name stays).
81
    pub author_id: Option<i64>,
82
    pub author: String,
83
    pub verdict: String,
84
    pub body: String,
85
    pub created_ms: i64,
86
    /// The AI assistant that answered for the author (migration 0036),
87
    /// empty for the author's own review.
88
    #[sqlx(default)]
89
    pub via: String,
90
}
91
92
#[derive(Clone, Debug, FromRow)]
93
pub struct EventRow {
94
    pub id: i64,
95
    pub at_ms: i64,
96
    pub actor: String,
97
    pub event: String,
98
    pub detail: String,
99
}
100
101
/// Take the next number of a repository's shared number space.
102117×
pub async fn next_number(pool: &Pool, repo_id: i64) -> sqlx::Result<i64> {
103117×
    let mut tx = super::begin_write(pool).await?;
104117×
    sqlx::query("INSERT INTO numbers (repo_id, next) VALUES ($1, 1) ON CONFLICT DO NOTHING").bind(repo_id).execute(&mut *tx).await?;
105117×
    let n: i64 = sqlx::query_scalar("SELECT next FROM numbers WHERE repo_id = $1").bind(repo_id).fetch_one(&mut *tx).await?;
106117×
    sqlx::query("UPDATE numbers SET next = next + 1 WHERE repo_id = $1").bind(repo_id).execute(&mut *tx).await?;
107117×
    tx.commit().await?;
108117×
    Ok(n)
109117×
}
110
111
pub struct NewProposal<'a> {
112
    pub repo_id: i64,
113
    pub title: &'a str,
114
    pub body: &'a str,
115
    pub author_id: Option<i64>,
116
    pub author: &'a str,
117
    pub draft: bool,
118
    pub target: &'a str,
119
    pub source_kind: &'a str,
120
    pub source_repo_id: Option<i64>,
121
    pub source_bookmark: &'a str,
122
    pub source_changes: &'a str,
123
}
124
12548×
pub async fn create(pool: &Pool, p: NewProposal<'_>) -> sqlx::Result<ProposalRow> {
12648×
    let number = next_number(pool, p.repo_id).await?;
12748×
    let now = now_ms();
12848×
    sqlx::query_as(
12948×
        "INSERT INTO proposals (repo_id, number, title, body, author_id, author, state, target, source_kind, source_repo_id, source_bookmark, source_changes, created_ms, updated_ms) \
13048×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *",
131
    )
13248×
    .bind(p.repo_id)
13348×
    .bind(number)
13448×
    .bind(p.title)
13548×
    .bind(p.body)
13648×
    .bind(p.author_id)
13748×
    .bind(p.author)
13848×
    .bind(if p.draft { "draft" } else { "open" })
13948×
    .bind(p.target)
14048×
    .bind(p.source_kind)
14148×
    .bind(p.source_repo_id)
14248×
    .bind(p.source_bookmark)
14348×
    .bind(p.source_changes)
14448×
    .bind(now)
14548×
    .bind(now)
14648×
    .fetch_one(pool)
14748×
    .await
14848×
}
149
150311×
pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<ProposalRow>> {
151311×
    sqlx::query_as("SELECT * FROM proposals WHERE repo_id = $1 AND number = $2").bind(repo_id).bind(number).fetch_optional(pool).await
152311×
}
153
154788×
pub async fn list(pool: &Pool, repo_id: i64, states: &[&str]) -> sqlx::Result<Vec<ProposalRow>> {
155788×
    let all: Vec<ProposalRow> =
156788×
        sqlx::query_as("SELECT * FROM proposals WHERE repo_id = $1 ORDER BY number DESC").bind(repo_id).fetch_all(pool).await?;
157788×
    Ok(all.into_iter().filter(|p| states.is_empty() || states.contains(&p.state.as_str())).collect())
158788×
}
159
160
/// Open proposals of every repository, for the front page.
1610×
pub async fn open_for_author(pool: &Pool, author_id: i64) -> sqlx::Result<Vec<ProposalRow>> {
1620×
    sqlx::query_as("SELECT * FROM proposals WHERE author_id = $1 AND state IN ('open', 'draft') ORDER BY updated_ms DESC")
1630×
        .bind(author_id)
1640×
        .fetch_all(pool)
1650×
        .await
1660×
}
167
168159×
pub async fn set_projection(
169159×
    pool: &Pool,
170159×
    id: i64,
171159×
    state: &str,
172159×
    heads: &str,
173159×
    target_state: &str,
174159×
    changes: &str,
175159×
    conflicted: bool,
176159×
) -> sqlx::Result<()> {
177159×
    sqlx::query("UPDATE proposals SET projected_state = $1, projected_heads = $2, projected_target = $3, projected_changes = $4, conflicted = $5 WHERE id = $6")
178159×
        .bind(state)
179159×
        .bind(heads)
180159×
        .bind(target_state)
181159×
        .bind(changes)
182159×
        .bind(conflicted)
183159×
        .bind(id)
184159×
        .execute(pool)
185159×
        .await?;
186159×
    Ok(())
187159×
}
188
1899×
pub async fn set_state(pool: &Pool, id: i64, state: &str, landed_state: &str) -> sqlx::Result<()> {
1909×
    sqlx::query("UPDATE proposals SET state = $1, landed_state = $2, updated_ms = $3 WHERE id = $4")
1919×
        .bind(state)
1929×
        .bind(landed_state)
1939×
        .bind(now_ms())
1949×
        .bind(id)
1959×
        .execute(pool)
1969×
        .await?;
1979×
    if state == "closed" || state == "landed" {
1983×
        super::issues::finished(pool, "proposals", id).await?;
1996×
    }
2009×
    Ok(())
2019×
}
202
203
/// Mark a proposal landed, with who landed it and how.
20423×
pub async fn set_landed(pool: &Pool, id: i64, landed_state: &str, by: &str, via: &str) -> sqlx::Result<()> {
20523×
    let now = now_ms();
20623×
    sqlx::query(
20723×
        "UPDATE proposals SET state = 'landed', landed_state = $1, landed_by = $2, landed_via = $3, landed_ms = $4, updated_ms = $5 WHERE id = $6",
20823×
    )
20923×
    .bind(landed_state)
21023×
    .bind(by)
21123×
    .bind(via)
21223×
    .bind(now)
21323×
    .bind(now)
21423×
    .bind(id)
21523×
    .execute(pool)
21623×
    .await?;
21723×
    super::issues::finished(pool, "proposals", id).await
21823×
}
219
2206×
pub async fn edit(pool: &Pool, id: i64, title: &str, body: &str, target: &str) -> sqlx::Result<()> {
2216×
    sqlx::query("UPDATE proposals SET title = $1, body = $2, target = $3, updated_ms = $4 WHERE id = $5")
2226×
        .bind(title)
2236×
        .bind(body)
2246×
        .bind(target)
2256×
        .bind(now_ms())
2266×
        .bind(id)
2276×
        .execute(pool)
2286×
        .await?;
2296×
    Ok(())
2306×
}
231
23211×
pub async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
23311×
    sqlx::query("UPDATE proposals SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
23411×
    Ok(())
23511×
}
236
23780×
pub async fn comments(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<CommentRow>> {
23880×
    sqlx::query_as("SELECT * FROM proposal_comments WHERE proposal_id = $1 ORDER BY created_ms, id").bind(proposal_id).fetch_all(pool).await
23980×
}
240
2414×
pub async fn add_comment(
2424×
    pool: &Pool,
2434×
    proposal_id: i64,
2444×
    author_id: Option<i64>,
2454×
    author: &str,
2464×
    body: &str,
2474×
    path: &str,
2484×
    anchor: Option<(&str, i64)>,
2494×
) -> sqlx::Result<CommentRow> {
2504×
    let row = sqlx::query_as(
2514×
        "INSERT INTO proposal_comments (proposal_id, author_id, author, body, path, anchor_change, anchor_index, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
252
    )
2534×
    .bind(proposal_id)
2544×
    .bind(author_id)
2554×
    .bind(author)
2564×
    .bind(body)
2574×
    .bind(path)
2584×
    .bind(anchor.map(|a| a.0).unwrap_or(""))
2594×
    .bind(anchor.map(|a| a.1).unwrap_or(0))
2604×
    .bind(now_ms())
2614×
    .fetch_one(pool)
2624×
    .await?;
2634×
    touch(pool, proposal_id).await?;
2644×
    Ok(row)
2654×
}
266
267
/// A conversation comment an AI assistant (`via`) wrote for `author`,
268
/// who asked it.
2691×
pub async fn add_comment_via(
2701×
    pool: &Pool,
2711×
    proposal_id: i64,
2721×
    author_id: i64,
2731×
    author: &str,
2741×
    body: &str,
2751×
    via: &str,
2761×
) -> sqlx::Result<CommentRow> {
2771×
    let row = sqlx::query_as(
2781×
        "INSERT INTO proposal_comments (proposal_id, author_id, author, body, path, anchor_change, anchor_index, created_ms, via) VALUES ($1, $2, $3, $4, '', '', 0, $5, $6) RETURNING *",
2791×
    )
2801×
    .bind(proposal_id)
2811×
    .bind(author_id)
2821×
    .bind(author)
2831×
    .bind(body)
2841×
    .bind(now_ms())
2851×
    .bind(via)
2861×
    .fetch_one(pool)
2871×
    .await?;
2881×
    touch(pool, proposal_id).await?;
2891×
    Ok(row)
2901×
}
291
2920×
pub async fn set_comment_resolved(pool: &Pool, id: i64, resolved: bool) -> sqlx::Result<()> {
2930×
    sqlx::query("UPDATE proposal_comments SET resolved = $1 WHERE id = $2").bind(resolved).bind(id).execute(pool).await?;
2940×
    Ok(())
2950×
}
296
297150×
pub async fn reviews(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<ReviewRow>> {
298150×
    sqlx::query_as("SELECT * FROM proposal_reviews WHERE proposal_id = $1 ORDER BY created_ms, id").bind(proposal_id).fetch_all(pool).await
299150×
}
300
3015×
pub async fn add_review(pool: &Pool, proposal_id: i64, author_id: i64, author: &str, verdict: &str, body: &str) -> sqlx::Result<ReviewRow> {
3025×
    add_review_via(pool, proposal_id, author_id, author, verdict, body, "").await
3035×
}
304
305
/// A review answered by an AI assistant (`via`) for `author`, who asked.
3066×
pub async fn add_review_via(
3076×
    pool: &Pool,
3086×
    proposal_id: i64,
3096×
    author_id: i64,
3106×
    author: &str,
3116×
    verdict: &str,
3126×
    body: &str,
3136×
    via: &str,
3146×
) -> sqlx::Result<ReviewRow> {
3156×
    let row = sqlx::query_as(
3166×
        "INSERT INTO proposal_reviews (proposal_id, author_id, author, verdict, body, created_ms, via) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
3176×
    )
3186×
    .bind(proposal_id)
3196×
    .bind(author_id)
3206×
    .bind(author)
3216×
    .bind(verdict)
3226×
    .bind(body)
3236×
    .bind(now_ms())
3246×
    .bind(via)
3256×
    .fetch_one(pool)
3266×
    .await?;
3276×
    touch(pool, proposal_id).await?;
3286×
    Ok(row)
3296×
}
330
33179×
pub async fn events(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<EventRow>> {
33279×
    sqlx::query_as("SELECT id, at_ms, actor, event, detail FROM proposal_events WHERE proposal_id = $1 ORDER BY at_ms, id")
33379×
        .bind(proposal_id)
33479×
        .fetch_all(pool)
33579×
        .await
33679×
}
337
338
/// Whether change `hash` (its short form) already references it (#318):
339
/// a change is recorded once, whichever bookmarks it is pushed on.
3401×
pub async fn referenced_by(pool: &Pool, proposal_id: i64, hash: &str) -> sqlx::Result<bool> {
3411×
    let n: i64 = sqlx::query_scalar(
3421×
        "SELECT COUNT(*) FROM proposal_events WHERE proposal_id = $1 AND event = 'referenced by change' AND (detail = $2 OR detail LIKE $3)",
3431×
    )
3441×
    .bind(proposal_id)
3451×
    .bind(hash)
3461×
    .bind(format!("{hash} %"))
3471×
    .fetch_one(pool)
3481×
    .await?;
3491×
    Ok(n > 0)
3501×
}
351
35298×
pub async fn add_event(pool: &Pool, proposal_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> {
35398×
    sqlx::query("INSERT INTO proposal_events (proposal_id, at_ms, actor, event, detail) VALUES ($1, $2, $3, $4, $5)")
35498×
        .bind(proposal_id)
35598×
        .bind(now_ms())
35698×
        .bind(actor)
35798×
        .bind(event)
35898×
        .bind(detail)
35998×
        .execute(pool)
36098×
        .await?;
36198×
    Ok(())
36298×
}
363
364
/// Landing requirements of a repository.
365
#[derive(Clone, Copy, Debug, Default, FromRow)]
366
pub struct Requirements {
367
    pub required_approvals: i64,
368
    pub require_owners: bool,
369
    pub allow_conflicts: bool,
370
}
371
372285×
pub async fn requirements(pool: &Pool, repo_id: i64) -> sqlx::Result<Requirements> {
373285×
    sqlx::query_as("SELECT required_approvals, require_owners, allow_conflicts FROM repositories WHERE id = $1")
374285×
        .bind(repo_id)
375285×
        .fetch_one(pool)
376285×
        .await
377285×
}
378
3794×
pub async fn set_requirements(pool: &Pool, repo_id: i64, r: Requirements) -> sqlx::Result<()> {
3804×
    sqlx::query("UPDATE repositories SET required_approvals = $1, require_owners = $2, allow_conflicts = $3 WHERE id = $4")
3814×
        .bind(r.required_approvals)
3824×
        .bind(r.require_owners)
3834×
        .bind(r.allow_conflicts)
3844×
        .bind(repo_id)
3854×
        .execute(pool)
3864×
        .await?;
3874×
    Ok(())
3884×
}
389
390
// ------------------------------------------------------- review requests
391
392
/// Ask an account to review a proposal; false when it was already asked.
3933×
pub async fn request_review(pool: &Pool, proposal_id: i64, account_id: i64, by: &str) -> sqlx::Result<bool> {
3943×
    let r = sqlx::query(
3953×
        "INSERT INTO proposal_review_requests (proposal_id, account_id, requested_by, requested_ms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
3963×
    )
3973×
    .bind(proposal_id)
3983×
    .bind(account_id)
3993×
    .bind(by)
4003×
    .bind(now_ms())
4013×
    .execute(pool)
4023×
    .await?;
4033×
    Ok(r.rows_affected() == 1)
4043×
}
405
406
/// Withdraw a request, or mark it answered; false when there was none.
4076×
pub async fn unrequest_review(pool: &Pool, proposal_id: i64, account_id: i64) -> sqlx::Result<bool> {
4086×
    let r = sqlx::query("DELETE FROM proposal_review_requests WHERE proposal_id = $1 AND account_id = $2")
4096×
        .bind(proposal_id)
4106×
        .bind(account_id)
4116×
        .execute(pool)
4126×
        .await?;
4136×
    Ok(r.rows_affected() == 1)
4146×
}
415
416
/// The usernames asked to review a proposal and not yet reviewing, in the
417
/// order they were asked.
418121×
pub async fn review_requests(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<String>> {
419121×
    sqlx::query_scalar(
420121×
        "SELECT a.username FROM proposal_review_requests r JOIN accounts a ON a.id = r.account_id \
421121×
         WHERE r.proposal_id = $1 ORDER BY r.requested_ms, a.username",
422121×
    )
423121×
    .bind(proposal_id)
424121×
    .fetch_all(pool)
425121×
    .await
426121×
}
427
428
/// Set, or with `by` empty clear, landing when checks pass (#261).
4296×
pub async fn set_auto_land(pool: &Pool, id: i64, by: &str) -> sqlx::Result<()> {
4306×
    sqlx::query("UPDATE proposals SET auto_land_by = $1, auto_land_ms = $2 WHERE id = $3")
4316×
        .bind(by)
4326×
        .bind((!by.is_empty()).then(now_ms))
4336×
        .bind(id)
4346×
        .execute(pool)
4356×
        .await?;
4366×
    Ok(())
4376×
}
438
439
/// A repository's open proposals set to land when their checks pass, on
440
/// projected state `state`.
441727×
pub async fn auto_landing(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<ProposalRow>> {
442727×
    sqlx::query_as(
443727×
        "SELECT * FROM proposals WHERE repo_id = $1 AND state = 'open' AND auto_land_by != '' AND projected_state = $2 ORDER BY number",
444727×
    )
445727×
    .bind(repo_id)
446727×
    .bind(state)
447727×
    .fetch_all(pool)
448727×
    .await
449727×
}