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