OXYGENHUB
◆ v0.4.2 oxygenhub / crates / hub / src / db / repos.rs
12.1 KiB · Rust · last changed Advanced settings: transfer, archive, delete, wiki placeholder 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! The `repositories` and `pushes` tables.
2
3
use super::{now_ms, Pool};
4
use hub_api::Visibility;
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, FromRow)]
8
pub struct RepoRow {
9
    pub id: i64,
10
    pub owner: String,
11
    pub name: String,
12
    pub description: String,
13
    pub default_bookmark: String,
14
    pub visibility: String,
15
    pub created_ms: i64,
16
    pub updated_ms: i64,
3f4b0ea6 Andy McCall ↶ 17
    #[sqlx(default)]
18
    pub git_mirror: bool,
19
    #[sqlx(default)]
20
    pub git_mirror_state: String,
07b1a5bb Andy McCall ↶ 21
    /// The repository this one was forked from (migration 0009).
22
    #[sqlx(default)]
23
    pub forked_from: Option<i64>,
1553bfc9 Andy McCall ↶ 24
    /// Space-separated topics (migration 0010).
25
    #[sqlx(default)]
26
    pub topics: String,
9008bf6c Andy McCall ↶ 27
    /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018).
28
    #[sqlx(default)]
29
    pub flow: String,
d0fbea2e Andy McCall ↶ 30
    /// Read-only, with why (migration 0040, #108).
31
    #[sqlx(default)]
32
    pub archived: bool,
33
    #[sqlx(default)]
34
    pub archived_reason: String,
1ac84c62 Andy McCall ↶ 35
    /// Counts for the tabs; filled by `get` only.
36
    #[sqlx(default)]
37
    pub open_issues: i64,
38
    #[sqlx(default)]
39
    pub open_proposals: i64,
d23464c0 Andy McCall ↶ 40
}
41
42
impl RepoRow {
43
    pub fn visibility(&self) -> Visibility {
44
        Visibility::parse(&self.visibility).unwrap_or(Visibility::Private)
45
    }
46
1553bfc9 Andy McCall ↶ 47
    pub fn topic_list(&self) -> Vec<&str> {
48
        self.topics.split_whitespace().collect()
49
    }
50
d23464c0 Andy McCall ↶ 51
    pub fn slug(&self) -> String {
52
        format!("{}/{}", self.owner, self.name)
53
    }
54
}
55
56
#[derive(Clone, Debug, FromRow)]
57
pub struct PushRow {
58
    pub id: i64,
59
    pub at_ms: i64,
60
    pub reference: String,
61
    pub ok: bool,
62
    pub reason: String,
62d6e969 Andy McCall ↶ 63
    pub old_state: String,
64
    pub new_state: String,
65
    pub heads: i64,
66
    pub changes: i64,
19bb31ff Andy McCall ↶ 67
    pub pusher: String,
62d6e969 Andy McCall ↶ 68
}
69
70
/// One reference's outcome in a push, as the protocol backend reports it.
71
#[derive(Clone, Debug)]
72
pub struct PushOutcome {
73
    pub reference: String,
74
    pub ok: bool,
75
    pub reason: String,
76
    pub old_state: String,
77
    pub new_state: String,
78
    pub heads: i64,
79
    pub changes: i64,
d23464c0 Andy McCall ↶ 80
}
81
82
pub async fn list(pool: &Pool) -> sqlx::Result<Vec<RepoRow>> {
83
    sqlx::query_as("SELECT * FROM repositories ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await
84
}
85
19bb31ff Andy McCall ↶ 86
pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<RepoRow>> {
87
    sqlx::query_as("SELECT * FROM repositories WHERE id = ?").bind(id).fetch_optional(pool).await
88
}
89
d23464c0 Andy McCall ↶ 90
pub async fn list_by_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> {
91
    sqlx::query_as("SELECT * FROM repositories WHERE owner = ? ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await
92
}
93
1ac84c62 Andy McCall ↶ 94
/// One repository, with its open issue and proposal counts for the tabs.
d23464c0 Andy McCall ↶ 95
pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
1ac84c62 Andy McCall ↶ 96
    sqlx::query_as(
97
        "SELECT r.*, \
98
           (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.state = 'open') AS open_issues, \
99
           (SELECT COUNT(*) FROM proposals p WHERE p.repo_id = r.id AND p.state IN ('open', 'draft')) AS open_proposals \
100
         FROM repositories r WHERE r.owner = ? AND r.name = ?",
101
    )
102
    .bind(owner)
103
    .bind(name)
104
    .fetch_optional(pool)
105
    .await
d23464c0 Andy McCall ↶ 106
}
107
ff656953 Andy McCall ↶ 108
/// Create a repository row. One under an organisation of two or more
109
/// members requires one sealed approval to land a proposal (oxygenhub#64),
110
/// since someone other than the author can then give it; others require none.
d23464c0 Andy McCall ↶ 111
pub async fn create(pool: &Pool, owner: &str, name: &str, description: &str, visibility: Visibility) -> sqlx::Result<RepoRow> {
112
    let now = now_ms();
113
    sqlx::query_as(
462f88d1 Andy McCall ↶ 114
        "INSERT INTO repositories (owner, name, description, visibility, created_ms, updated_ms, required_approvals) \
ff656953 Andy McCall ↶ 115
         VALUES (?, ?, ?, ?, ?, ?, (SELECT COUNT(*) >= 2 FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = ?)) RETURNING *",
d23464c0 Andy McCall ↶ 116
    )
117
    .bind(owner)
118
    .bind(name)
119
    .bind(description)
120
    .bind(visibility.as_str())
121
    .bind(now)
122
    .bind(now)
462f88d1 Andy McCall ↶ 123
    .bind(owner)
d23464c0 Andy McCall ↶ 124
    .fetch_one(pool)
125
    .await
d0fbea2e Andy McCall ↶ 126
}
127
128
/// The repository an old name now points at, after a transfer.
129
pub async fn moved_to(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
130
    let id: Option<i64> = sqlx::query_scalar("SELECT repo_id FROM repo_redirects WHERE old_owner = ? AND old_name = ?")
131
        .bind(owner)
132
        .bind(name)
133
        .fetch_optional(pool)
134
        .await?;
135
    match id {
136
        Some(id) => get_by_id(pool, id).await,
137
        None => Ok(None),
138
    }
139
}
140
141
/// Move a repository to another owner, keeping its name: the row, and a
142
/// redirect from the old name. A redirect that pointed at the new name
143
/// goes, so names never loop.
144
pub async fn transfer(pool: &Pool, id: i64, old_owner: &str, name: &str, new_owner: &str) -> sqlx::Result<()> {
145
    sqlx::query("UPDATE repositories SET owner = ?, updated_ms = ? WHERE id = ?")
146
        .bind(new_owner)
147
        .bind(now_ms())
148
        .bind(id)
149
        .execute(pool)
150
        .await?;
151
    sqlx::query("DELETE FROM repo_redirects WHERE old_owner = ? AND old_name = ?").bind(new_owner).bind(name).execute(pool).await?;
152
    sqlx::query("INSERT OR REPLACE INTO repo_redirects (old_owner, old_name, repo_id, moved_ms) VALUES (?, ?, ?, ?)")
153
        .bind(old_owner)
154
        .bind(name)
155
        .bind(id)
156
        .bind(now_ms())
157
        .execute(pool)
158
        .await?;
159
    Ok(())
160
}
161
162
/// Archive (read-only) or bring back a repository.
163
pub async fn set_archived(pool: &Pool, id: i64, archived: bool, reason: &str) -> sqlx::Result<()> {
164
    sqlx::query("UPDATE repositories SET archived = ?, archived_reason = ?, updated_ms = ? WHERE id = ?")
165
        .bind(archived)
166
        .bind(if archived { reason } else { "" })
167
        .bind(now_ms())
168
        .bind(id)
169
        .execute(pool)
170
        .await?;
171
    Ok(())
d23464c0 Andy McCall ↶ 172
}
173
174
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
175
    sqlx::query("DELETE FROM repositories WHERE id = ?").bind(id).execute(pool).await?;
176
    Ok(())
177
}
178
179
pub async fn set_description(pool: &Pool, id: i64, description: &str) -> sqlx::Result<()> {
180
    sqlx::query("UPDATE repositories SET description = ? WHERE id = ?").bind(description).bind(id).execute(pool).await?;
181
    Ok(())
182
}
183
184
pub async fn set_default_bookmark(pool: &Pool, id: i64, bookmark: &str) -> sqlx::Result<()> {
185
    sqlx::query("UPDATE repositories SET default_bookmark = ? WHERE id = ?").bind(bookmark).bind(id).execute(pool).await?;
186
    Ok(())
187
}
188
189
pub async fn set_visibility(pool: &Pool, id: i64, visibility: Visibility) -> sqlx::Result<()> {
190
    sqlx::query("UPDATE repositories SET visibility = ? WHERE id = ?").bind(visibility.as_str()).bind(id).execute(pool).await?;
191
    Ok(())
192
}
193
194
/// Record a push's per-reference results and bump the repository's activity.
19bb31ff Andy McCall ↶ 195
pub async fn record_push(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, results: &[PushOutcome]) -> sqlx::Result<()> {
d23464c0 Andy McCall ↶ 196
    let now = now_ms();
941abdd6 Andy McCall ↶ 197
    let mut tx = super::begin_write(pool).await?;
62d6e969 Andy McCall ↶ 198
    for r in results {
19bb31ff Andy McCall ↶ 199
        sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
d23464c0 Andy McCall ↶ 200
            .bind(id)
201
            .bind(now)
62d6e969 Andy McCall ↶ 202
            .bind(&r.reference)
203
            .bind(r.ok)
204
            .bind(&r.reason)
205
            .bind(&r.old_state)
206
            .bind(&r.new_state)
207
            .bind(r.heads)
208
            .bind(r.changes)
19bb31ff Andy McCall ↶ 209
            .bind(pusher.map(|p| p.0).filter(|id| *id > 0))
210
            .bind(pusher.map(|p| p.1).unwrap_or(""))
d23464c0 Andy McCall ↶ 211
            .execute(&mut *tx)
212
            .await?;
213
    }
62d6e969 Andy McCall ↶ 214
    if results.iter().any(|r| r.ok) {
d23464c0 Andy McCall ↶ 215
        sqlx::query("UPDATE repositories SET updated_ms = ? WHERE id = ?").bind(now).bind(id).execute(&mut *tx).await?;
216
    }
217
    tx.commit().await
218
}
219
220
pub async fn pushes(pool: &Pool, id: i64, limit: i64) -> sqlx::Result<Vec<PushRow>> {
19bb31ff Andy McCall ↶ 221
    sqlx::query_as("SELECT id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, pusher FROM pushes WHERE repo_id = ? ORDER BY at_ms DESC, id DESC LIMIT ?")
d23464c0 Andy McCall ↶ 222
        .bind(id)
223
        .bind(limit)
224
        .fetch_all(pool)
225
        .await
226
}
3f4b0ea6 Andy McCall ↶ 227
1553bfc9 Andy McCall ↶ 228
/// Topics from user input: comma or space separated, lower-cased,
229
/// letters, digits and dashes, at most 35 characters each and 20 in all.
230
/// Returns the stored form or what is wrong.
231
pub fn parse_topics(input: &str) -> Result<String, String> {
232
    let mut out: Vec<String> = Vec::new();
233
    for t in input.split([',', ' ', '\n', '\t']).map(|t| t.trim().to_ascii_lowercase()).filter(|t| !t.is_empty()) {
234
        if t.len() > 35 || !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') || t.starts_with('-') {
235
            return Err(format!("\"{t}\" is not a topic: use letters, digits and dashes, up to 35 characters"));
236
        }
237
        if !out.contains(&t) {
238
            out.push(t);
239
        }
240
    }
241
    if out.len() > 20 {
242
        return Err("At most 20 topics.".into());
243
    }
244
    Ok(out.join(" "))
245
}
246
247
pub async fn set_topics(pool: &Pool, id: i64, topics: &str) -> sqlx::Result<()> {
248
    sqlx::query("UPDATE repositories SET topics = ? WHERE id = ?").bind(topics).bind(id).execute(pool).await?;
249
    Ok(())
250
}
251
252
/// Star or unstar; returns whether it is now starred.
253
pub async fn toggle_star(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
254
    let gone = sqlx::query("DELETE FROM stars WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?;
255
    if gone.rows_affected() > 0 {
256
        return Ok(false);
257
    }
258
    sqlx::query("INSERT INTO stars (account_id, repo_id, at_ms) VALUES (?, ?, ?)")
259
        .bind(account_id)
260
        .bind(repo_id)
261
        .bind(now_ms())
262
        .execute(pool)
263
        .await?;
264
    Ok(true)
265
}
266
267
pub async fn starred(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
268
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE account_id = ? AND repo_id = ?")
269
        .bind(account_id)
270
        .bind(repo_id)
271
        .fetch_one(pool)
272
        .await?;
273
    Ok(n > 0)
274
}
275
276
/// Stars and watchers of one repository.
277
pub async fn counts(pool: &Pool, repo_id: i64) -> sqlx::Result<(i64, i64)> {
278
    let stars: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
279
    let watchers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
280
    Ok((stars, watchers))
281
}
282
283
/// Star counts of every starred repository.
284
pub async fn star_counts(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
285
    let rows: Vec<(i64, i64)> = sqlx::query_as("SELECT repo_id, COUNT(*) FROM stars GROUP BY repo_id").fetch_all(pool).await?;
286
    Ok(rows.into_iter().collect())
287
}
288
07b1a5bb Andy McCall ↶ 289
/// Record that `id` is a fork of `from`.
290
pub async fn set_forked_from(pool: &Pool, id: i64, from: i64) -> sqlx::Result<()> {
291
    sqlx::query("UPDATE repositories SET forked_from = ? WHERE id = ?").bind(from).bind(id).execute(pool).await?;
292
    Ok(())
293
}
294
295
/// The forks of `id`, newest activity first.
296
pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> {
297
    sqlx::query_as("SELECT * FROM repositories WHERE forked_from = ? ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await
9008bf6c Andy McCall ↶ 298
}
299
300
/// `on`, `off`, or `` (follow hub.toml's `[flow] repos`).
301
pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
302
    sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?;
303
    Ok(())
07b1a5bb Andy McCall ↶ 304
}
305
3f4b0ea6 Andy McCall ↶ 306
pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
307
    sqlx::query("UPDATE repositories SET git_mirror = ? WHERE id = ?").bind(on).bind(id).execute(pool).await?;
308
    Ok(())
309
}
310
311
pub async fn set_git_mirror_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
312
    sqlx::query("UPDATE repositories SET git_mirror_state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
313
    Ok(())
314
}
1553bfc9 Andy McCall ↶ 315
316
#[cfg(test)]
317
mod topic_tests {
318
    use super::parse_topics;
319
320
    #[test]
321
    fn topics_are_normalised_and_checked() {
322
        assert_eq!(parse_topics("Rust, vcs  retro-computing,rust").unwrap(), "rust vcs retro-computing");
323
        assert_eq!(parse_topics("").unwrap(), "");
324
        assert!(parse_topics("c++").is_err());
325
        assert!(parse_topics("-lead").is_err());
326
        assert!(parse_topics(&"x".repeat(36)).is_err());
327
        assert!(parse_topics(&(0..21).map(|i| format!("t{i}")).collect::<Vec<_>>().join(" ")).is_err());
328
    }
329
}