OXYGENHUB
◆ v0.6.0 oxygenhub / crates / hub / src / db / repos.rs
15.2 KiB · Rust · last changed Push protection: refuse a push that adds what looks like a secret 17 days 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
e7158350 Andy McCall ↶ 7
#[derive(Clone, Debug, Default, FromRow)]
d23464c0 Andy McCall ↶ 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,
107eaba5 Andy McCall ↶ 30
    /// Its jobs' priority: `low`, `normal`, `high`, or empty to follow its
31
    /// organisation's and the site's (oxygenhub#244, migration 0073).
32
    #[sqlx(default)]
33
    pub flow_priority: String,
e7158350 Andy McCall ↶ 34
    /// Diffusion: `off`, `green`, `any`, or empty to follow hub.toml
35
    /// (migration 0058, #148).
36
    #[sqlx(default)]
37
    pub diffusion: String,
f4d6ef41 Andy McCall ↶ 38
    /// Diffusion auto-land: `auto`, `propose`, or empty for propose
39
    /// (migration 0061).
40
    #[sqlx(default)]
41
    pub diffusion_land: String,
d3b048b2 Andy McCall ↶ 42
    /// Backups (migration 0062): empty with its owner's, `off`, or its own
43
    /// `hourly` or `daily`.
44
    #[sqlx(default)]
45
    pub backup: String,
d0fbea2e Andy McCall ↶ 46
    /// Read-only, with why (migration 0040, #108).
47
    #[sqlx(default)]
48
    pub archived: bool,
49
    #[sqlx(default)]
50
    pub archived_reason: String,
4a68f106 Andy McCall ↶ 51
    /// Agents' tokens may approve or reject its approval gates (#258).
52
    #[sqlx(default)]
53
    pub agents_decide_gates: bool,
a9f1c0bf Andy McCall ↶ 54
    /// Newest run wins (#267): a superseded run already running finishes;
55
    /// only queued ones are cancelled. Off by default.
56
    #[sqlx(default)]
57
    pub flow_keep_running: bool,
0ee5c3fb Andy McCall ↶ 58
    /// Pushes that add possible secrets are refused (#268, migration
59
    /// 0085). On by default.
60
    #[sqlx(default)]
61
    pub push_protection: bool,
1ac84c62 Andy McCall ↶ 62
    /// Counts for the tabs; filled by `get` only.
63
    #[sqlx(default)]
64
    pub open_issues: i64,
65
    #[sqlx(default)]
66
    pub open_proposals: i64,
d23464c0 Andy McCall ↶ 67
}
68
69
impl RepoRow {
70
    pub fn visibility(&self) -> Visibility {
71
        Visibility::parse(&self.visibility).unwrap_or(Visibility::Private)
72
    }
73
1553bfc9 Andy McCall ↶ 74
    pub fn topic_list(&self) -> Vec<&str> {
75
        self.topics.split_whitespace().collect()
76
    }
77
d23464c0 Andy McCall ↶ 78
    pub fn slug(&self) -> String {
79
        format!("{}/{}", self.owner, self.name)
80
    }
81
}
82
83
#[derive(Clone, Debug, FromRow)]
84
pub struct PushRow {
85
    pub id: i64,
86
    pub at_ms: i64,
87
    pub reference: String,
88
    pub ok: bool,
89
    pub reason: String,
62d6e969 Andy McCall ↶ 90
    pub old_state: String,
91
    pub new_state: String,
92
    pub heads: i64,
93
    pub changes: i64,
19bb31ff Andy McCall ↶ 94
    pub pusher: String,
62d6e969 Andy McCall ↶ 95
}
96
97
/// One reference's outcome in a push, as the protocol backend reports it.
98
#[derive(Clone, Debug)]
99
pub struct PushOutcome {
100
    pub reference: String,
101
    pub ok: bool,
102
    pub reason: String,
103
    pub old_state: String,
104
    pub new_state: String,
105
    pub heads: i64,
106
    pub changes: i64,
d23464c0 Andy McCall ↶ 107
}
108
109
pub async fn list(pool: &Pool) -> sqlx::Result<Vec<RepoRow>> {
110
    sqlx::query_as("SELECT * FROM repositories ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await
111
}
112
19bb31ff Andy McCall ↶ 113
pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<RepoRow>> {
5d90e0e0 Andy McCall 114
    sqlx::query_as("SELECT * FROM repositories WHERE id = $1").bind(id).fetch_optional(pool).await
19bb31ff Andy McCall ↶ 115
}
116
d23464c0 Andy McCall ↶ 117
pub async fn list_by_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> {
5d90e0e0 Andy McCall 118
    sqlx::query_as("SELECT * FROM repositories WHERE owner = $1 ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await
d23464c0 Andy McCall ↶ 119
}
120
1ac84c62 Andy McCall ↶ 121
/// One repository, with its open issue and proposal counts for the tabs.
d23464c0 Andy McCall ↶ 122
pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
1ac84c62 Andy McCall ↶ 123
    sqlx::query_as(
124
        "SELECT r.*, \
125
           (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.state = 'open') AS open_issues, \
126
           (SELECT COUNT(*) FROM proposals p WHERE p.repo_id = r.id AND p.state IN ('open', 'draft')) AS open_proposals \
5d90e0e0 Andy McCall 127
         FROM repositories r WHERE r.owner = $1 AND r.name = $2",
1ac84c62 Andy McCall ↶ 128
    )
129
    .bind(owner)
130
    .bind(name)
131
    .fetch_optional(pool)
132
    .await
d23464c0 Andy McCall ↶ 133
}
134
ff656953 Andy McCall ↶ 135
/// Create a repository row. One under an organisation of two or more
136
/// members requires one sealed approval to land a proposal (oxygenhub#64),
137
/// since someone other than the author can then give it; others require none.
d23464c0 Andy McCall ↶ 138
pub async fn create(pool: &Pool, owner: &str, name: &str, description: &str, visibility: Visibility) -> sqlx::Result<RepoRow> {
139
    let now = now_ms();
140
    sqlx::query_as(
462f88d1 Andy McCall ↶ 141
        "INSERT INTO repositories (owner, name, description, visibility, created_ms, updated_ms, required_approvals) \
3b1bc27f Andy McCall 142
         VALUES ($1, $2, $3, $4, $5, $6, (SELECT CASE WHEN COUNT(*) >= 2 THEN 1 ELSE 0 END FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = $7)) RETURNING *",
d23464c0 Andy McCall ↶ 143
    )
144
    .bind(owner)
145
    .bind(name)
146
    .bind(description)
147
    .bind(visibility.as_str())
148
    .bind(now)
149
    .bind(now)
462f88d1 Andy McCall ↶ 150
    .bind(owner)
d23464c0 Andy McCall ↶ 151
    .fetch_one(pool)
152
    .await
d0fbea2e Andy McCall ↶ 153
}
154
155
/// The repository an old name now points at, after a transfer.
156
pub async fn moved_to(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
5d90e0e0 Andy McCall 157
    let id: Option<i64> = sqlx::query_scalar("SELECT repo_id FROM repo_redirects WHERE old_owner = $1 AND old_name = $2")
d0fbea2e Andy McCall ↶ 158
        .bind(owner)
159
        .bind(name)
160
        .fetch_optional(pool)
161
        .await?;
162
    match id {
163
        Some(id) => get_by_id(pool, id).await,
164
        None => Ok(None),
165
    }
166
}
167
168
/// Move a repository to another owner, keeping its name: the row, and a
169
/// redirect from the old name. A redirect that pointed at the new name
170
/// goes, so names never loop.
171
pub async fn transfer(pool: &Pool, id: i64, old_owner: &str, name: &str, new_owner: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 172
    sqlx::query("UPDATE repositories SET owner = $1, updated_ms = $2 WHERE id = $3")
d0fbea2e Andy McCall ↶ 173
        .bind(new_owner)
174
        .bind(now_ms())
175
        .bind(id)
176
        .execute(pool)
177
        .await?;
5d90e0e0 Andy McCall 178
    sqlx::query("DELETE FROM repo_redirects WHERE old_owner = $1 AND old_name = $2").bind(new_owner).bind(name).execute(pool).await?;
d1bf2d69 Andy McCall ↶ 179
    sqlx::query("INSERT INTO repo_redirects (old_owner, old_name, repo_id, moved_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (old_owner, old_name) DO UPDATE SET repo_id = excluded.repo_id, moved_ms = excluded.moved_ms")
d0fbea2e Andy McCall ↶ 180
        .bind(old_owner)
181
        .bind(name)
182
        .bind(id)
183
        .bind(now_ms())
184
        .execute(pool)
185
        .await?;
186
    Ok(())
187
}
188
189
/// Archive (read-only) or bring back a repository.
190
pub async fn set_archived(pool: &Pool, id: i64, archived: bool, reason: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 191
    sqlx::query("UPDATE repositories SET archived = $1, archived_reason = $2, updated_ms = $3 WHERE id = $4")
d0fbea2e Andy McCall ↶ 192
        .bind(archived)
193
        .bind(if archived { reason } else { "" })
194
        .bind(now_ms())
195
        .bind(id)
196
        .execute(pool)
197
        .await?;
198
    Ok(())
d23464c0 Andy McCall ↶ 199
}
200
201
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 202
    sqlx::query("DELETE FROM repositories WHERE id = $1").bind(id).execute(pool).await?;
d23464c0 Andy McCall ↶ 203
    Ok(())
204
}
205
206
pub async fn set_description(pool: &Pool, id: i64, description: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 207
    sqlx::query("UPDATE repositories SET description = $1 WHERE id = $2").bind(description).bind(id).execute(pool).await?;
d23464c0 Andy McCall ↶ 208
    Ok(())
209
}
210
211
pub async fn set_default_bookmark(pool: &Pool, id: i64, bookmark: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 212
    sqlx::query("UPDATE repositories SET default_bookmark = $1 WHERE id = $2").bind(bookmark).bind(id).execute(pool).await?;
d23464c0 Andy McCall ↶ 213
    Ok(())
214
}
215
216
pub async fn set_visibility(pool: &Pool, id: i64, visibility: Visibility) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 217
    sqlx::query("UPDATE repositories SET visibility = $1 WHERE id = $2").bind(visibility.as_str()).bind(id).execute(pool).await?;
d23464c0 Andy McCall ↶ 218
    Ok(())
219
}
220
221
/// Record a push's per-reference results and bump the repository's activity.
19bb31ff Andy McCall ↶ 222
pub async fn record_push(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, results: &[PushOutcome]) -> sqlx::Result<()> {
2a0c09c0 Andy McCall ↶ 223
    record_push_via(pool, id, pusher, "", results).await
224
}
225
226
/// [`record_push`], naming the agent token the push came through (#217).
227
pub async fn record_push_via(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, via: &str, results: &[PushOutcome]) -> sqlx::Result<()> {
d23464c0 Andy McCall ↶ 228
    let now = now_ms();
941abdd6 Andy McCall ↶ 229
    let mut tx = super::begin_write(pool).await?;
62d6e969 Andy McCall ↶ 230
    for r in results {
5d90e0e0 Andy McCall 231
        sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher, via) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
d23464c0 Andy McCall ↶ 232
            .bind(id)
233
            .bind(now)
62d6e969 Andy McCall ↶ 234
            .bind(&r.reference)
235
            .bind(r.ok)
236
            .bind(&r.reason)
237
            .bind(&r.old_state)
238
            .bind(&r.new_state)
239
            .bind(r.heads)
240
            .bind(r.changes)
19bb31ff Andy McCall ↶ 241
            .bind(pusher.map(|p| p.0).filter(|id| *id > 0))
242
            .bind(pusher.map(|p| p.1).unwrap_or(""))
2a0c09c0 Andy McCall ↶ 243
            .bind(via)
d23464c0 Andy McCall ↶ 244
            .execute(&mut *tx)
245
            .await?;
246
    }
62d6e969 Andy McCall ↶ 247
    if results.iter().any(|r| r.ok) {
5d90e0e0 Andy McCall 248
        sqlx::query("UPDATE repositories SET updated_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?;
d23464c0 Andy McCall ↶ 249
    }
250
    tx.commit().await
251
}
252
253
pub async fn pushes(pool: &Pool, id: i64, limit: i64) -> sqlx::Result<Vec<PushRow>> {
5d90e0e0 Andy McCall 254
    sqlx::query_as("SELECT id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, pusher FROM pushes WHERE repo_id = $1 ORDER BY at_ms DESC, id DESC LIMIT $2")
d23464c0 Andy McCall ↶ 255
        .bind(id)
256
        .bind(limit)
257
        .fetch_all(pool)
258
        .await
259
}
3f4b0ea6 Andy McCall ↶ 260
1553bfc9 Andy McCall ↶ 261
/// Topics from user input: comma or space separated, lower-cased,
262
/// letters, digits and dashes, at most 35 characters each and 20 in all.
263
/// Returns the stored form or what is wrong.
264
pub fn parse_topics(input: &str) -> Result<String, String> {
265
    let mut out: Vec<String> = Vec::new();
266
    for t in input.split([',', ' ', '\n', '\t']).map(|t| t.trim().to_ascii_lowercase()).filter(|t| !t.is_empty()) {
267
        if t.len() > 35 || !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') || t.starts_with('-') {
268
            return Err(format!("\"{t}\" is not a topic: use letters, digits and dashes, up to 35 characters"));
269
        }
270
        if !out.contains(&t) {
271
            out.push(t);
272
        }
273
    }
274
    if out.len() > 20 {
275
        return Err("At most 20 topics.".into());
276
    }
277
    Ok(out.join(" "))
278
}
279
280
pub async fn set_topics(pool: &Pool, id: i64, topics: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 281
    sqlx::query("UPDATE repositories SET topics = $1 WHERE id = $2").bind(topics).bind(id).execute(pool).await?;
1553bfc9 Andy McCall ↶ 282
    Ok(())
283
}
284
285
/// Star or unstar; returns whether it is now starred.
286
pub async fn toggle_star(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
5d90e0e0 Andy McCall 287
    let gone = sqlx::query("DELETE FROM stars WHERE account_id = $1 AND repo_id = $2").bind(account_id).bind(repo_id).execute(pool).await?;
1553bfc9 Andy McCall ↶ 288
    if gone.rows_affected() > 0 {
289
        return Ok(false);
290
    }
5d90e0e0 Andy McCall 291
    sqlx::query("INSERT INTO stars (account_id, repo_id, at_ms) VALUES ($1, $2, $3)")
1553bfc9 Andy McCall ↶ 292
        .bind(account_id)
293
        .bind(repo_id)
294
        .bind(now_ms())
295
        .execute(pool)
296
        .await?;
297
    Ok(true)
298
}
299
300
pub async fn starred(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
5d90e0e0 Andy McCall 301
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE account_id = $1 AND repo_id = $2")
1553bfc9 Andy McCall ↶ 302
        .bind(account_id)
303
        .bind(repo_id)
304
        .fetch_one(pool)
305
        .await?;
306
    Ok(n > 0)
307
}
308
309
/// Stars and watchers of one repository.
310
pub async fn counts(pool: &Pool, repo_id: i64) -> sqlx::Result<(i64, i64)> {
5d90e0e0 Andy McCall 311
    let stars: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = $1").bind(repo_id).fetch_one(pool).await?;
bff9106e Andy McCall ↶ 312
    let watchers = super::notify::watchers(pool, repo_id).await?.len() as i64;
1553bfc9 Andy McCall ↶ 313
    Ok((stars, watchers))
314
}
315
316
/// Star counts of every starred repository.
317
pub async fn star_counts(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
318
    let rows: Vec<(i64, i64)> = sqlx::query_as("SELECT repo_id, COUNT(*) FROM stars GROUP BY repo_id").fetch_all(pool).await?;
319
    Ok(rows.into_iter().collect())
320
}
321
07b1a5bb Andy McCall ↶ 322
/// Record that `id` is a fork of `from`.
323
pub async fn set_forked_from(pool: &Pool, id: i64, from: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 324
    sqlx::query("UPDATE repositories SET forked_from = $1 WHERE id = $2").bind(from).bind(id).execute(pool).await?;
07b1a5bb Andy McCall ↶ 325
    Ok(())
326
}
327
328
/// The forks of `id`, newest activity first.
329
pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> {
5d90e0e0 Andy McCall 330
    sqlx::query_as("SELECT * FROM repositories WHERE forked_from = $1 ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await
9008bf6c Andy McCall ↶ 331
}
332
333
/// `on`, `off`, or `` (follow hub.toml's `[flow] repos`).
334
pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 335
    sqlx::query("UPDATE repositories SET flow = $1 WHERE id = $2").bind(setting).bind(id).execute(pool).await?;
107eaba5 Andy McCall ↶ 336
    Ok(())
337
}
338
339
pub async fn set_flow_priority(pool: &Pool, id: i64, priority: &str) -> sqlx::Result<()> {
340
    sqlx::query("UPDATE repositories SET flow_priority = $1 WHERE id = $2").bind(priority).bind(id).execute(pool).await?;
4a68f106 Andy McCall ↶ 341
    Ok(())
342
}
343
344
/// Whether agents' tokens may decide the repository's approval gates (#258).
345
pub async fn set_agents_decide_gates(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
346
    sqlx::query("UPDATE repositories SET agents_decide_gates = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
a9f1c0bf Andy McCall ↶ 347
    Ok(())
348
}
349
350
/// Whether a new run leaves the older runs it supersedes that are already
351
/// running to finish (#267), cancelling only the queued ones.
352
pub async fn set_flow_keep_running(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
353
    sqlx::query("UPDATE repositories SET flow_keep_running = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
d3b048b2 Andy McCall ↶ 354
    Ok(())
355
}
356
357
pub async fn set_backup(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 358
    sqlx::query("UPDATE repositories SET backup = $1 WHERE id = $2").bind(setting).bind(id).execute(pool).await?;
f4d6ef41 Andy McCall ↶ 359
    Ok(())
360
}
361
362
pub async fn set_diffusion_land(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 363
    sqlx::query("UPDATE repositories SET diffusion_land = $1 WHERE id = $2").bind(setting).bind(id).execute(pool).await?;
e7158350 Andy McCall ↶ 364
    Ok(())
365
}
366
367
pub async fn set_diffusion(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 368
    sqlx::query("UPDATE repositories SET diffusion = $1 WHERE id = $2").bind(setting).bind(id).execute(pool).await?;
9008bf6c Andy McCall ↶ 369
    Ok(())
07b1a5bb Andy McCall ↶ 370
}
371
3f4b0ea6 Andy McCall ↶ 372
pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 373
    sqlx::query("UPDATE repositories SET git_mirror = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
3f4b0ea6 Andy McCall ↶ 374
    Ok(())
375
}
376
377
pub async fn set_git_mirror_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 378
    sqlx::query("UPDATE repositories SET git_mirror_state = $1 WHERE id = $2").bind(state).bind(id).execute(pool).await?;
3f4b0ea6 Andy McCall ↶ 379
    Ok(())
380
}
1553bfc9 Andy McCall ↶ 381
382
#[cfg(test)]
383
mod topic_tests {
384
    use super::parse_topics;
385
386
    #[test]
387
    fn topics_are_normalised_and_checked() {
388
        assert_eq!(parse_topics("Rust, vcs  retro-computing,rust").unwrap(), "rust vcs retro-computing");
389
        assert_eq!(parse_topics("").unwrap(), "");
390
        assert!(parse_topics("c++").is_err());
391
        assert!(parse_topics("-lead").is_err());
392
        assert!(parse_topics(&"x".repeat(36)).is_err());
393
        assert!(parse_topics(&(0..21).map(|i| format!("t{i}")).collect::<Vec<_>>().join(" ")).is_err());
394
    }
395
}