//! The `repositories` and `pushes` tables. use super::{now_ms, Pool}; use hub_api::Visibility; use sqlx::FromRow; #[derive(Clone, Debug, Default, FromRow)] pub struct RepoRow { pub id: i64, pub owner: String, pub name: String, pub description: String, pub default_bookmark: String, pub visibility: String, pub created_ms: i64, pub updated_ms: i64, #[sqlx(default)] pub git_mirror: bool, #[sqlx(default)] pub git_mirror_state: String, /// The repository this one was forked from (migration 0009). #[sqlx(default)] pub forked_from: Option, /// Space-separated topics (migration 0010). #[sqlx(default)] pub topics: String, /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018). #[sqlx(default)] pub flow: String, /// Diffusion: `off`, `green`, `any`, or empty to follow hub.toml /// (migration 0058, #148). #[sqlx(default)] pub diffusion: String, /// Read-only, with why (migration 0040, #108). #[sqlx(default)] pub archived: bool, #[sqlx(default)] pub archived_reason: String, /// Counts for the tabs; filled by `get` only. #[sqlx(default)] pub open_issues: i64, #[sqlx(default)] pub open_proposals: i64, } impl RepoRow { pub fn visibility(&self) -> Visibility { Visibility::parse(&self.visibility).unwrap_or(Visibility::Private) } pub fn topic_list(&self) -> Vec<&str> { self.topics.split_whitespace().collect() } pub fn slug(&self) -> String { format!("{}/{}", self.owner, self.name) } } #[derive(Clone, Debug, FromRow)] pub struct PushRow { pub id: i64, pub at_ms: i64, pub reference: String, pub ok: bool, pub reason: String, pub old_state: String, pub new_state: String, pub heads: i64, pub changes: i64, pub pusher: String, } /// One reference's outcome in a push, as the protocol backend reports it. #[derive(Clone, Debug)] pub struct PushOutcome { pub reference: String, pub ok: bool, pub reason: String, pub old_state: String, pub new_state: String, pub heads: i64, pub changes: i64, } pub async fn list(pool: &Pool) -> sqlx::Result> { sqlx::query_as("SELECT * FROM repositories ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await } pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result> { sqlx::query_as("SELECT * FROM repositories WHERE id = ?").bind(id).fetch_optional(pool).await } pub async fn list_by_owner(pool: &Pool, owner: &str) -> sqlx::Result> { sqlx::query_as("SELECT * FROM repositories WHERE owner = ? ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await } /// One repository, with its open issue and proposal counts for the tabs. pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result> { sqlx::query_as( "SELECT r.*, \ (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.state = 'open') AS open_issues, \ (SELECT COUNT(*) FROM proposals p WHERE p.repo_id = r.id AND p.state IN ('open', 'draft')) AS open_proposals \ FROM repositories r WHERE r.owner = ? AND r.name = ?", ) .bind(owner) .bind(name) .fetch_optional(pool) .await } /// Create a repository row. One under an organisation of two or more /// members requires one sealed approval to land a proposal (oxygenhub#64), /// since someone other than the author can then give it; others require none. pub async fn create(pool: &Pool, owner: &str, name: &str, description: &str, visibility: Visibility) -> sqlx::Result { let now = now_ms(); sqlx::query_as( "INSERT INTO repositories (owner, name, description, visibility, created_ms, updated_ms, required_approvals) \ VALUES (?, ?, ?, ?, ?, ?, (SELECT COUNT(*) >= 2 FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = ?)) RETURNING *", ) .bind(owner) .bind(name) .bind(description) .bind(visibility.as_str()) .bind(now) .bind(now) .bind(owner) .fetch_one(pool) .await } /// The repository an old name now points at, after a transfer. pub async fn moved_to(pool: &Pool, owner: &str, name: &str) -> sqlx::Result> { let id: Option = sqlx::query_scalar("SELECT repo_id FROM repo_redirects WHERE old_owner = ? AND old_name = ?") .bind(owner) .bind(name) .fetch_optional(pool) .await?; match id { Some(id) => get_by_id(pool, id).await, None => Ok(None), } } /// Move a repository to another owner, keeping its name: the row, and a /// redirect from the old name. A redirect that pointed at the new name /// goes, so names never loop. pub async fn transfer(pool: &Pool, id: i64, old_owner: &str, name: &str, new_owner: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET owner = ?, updated_ms = ? WHERE id = ?") .bind(new_owner) .bind(now_ms()) .bind(id) .execute(pool) .await?; sqlx::query("DELETE FROM repo_redirects WHERE old_owner = ? AND old_name = ?").bind(new_owner).bind(name).execute(pool).await?; sqlx::query("INSERT OR REPLACE INTO repo_redirects (old_owner, old_name, repo_id, moved_ms) VALUES (?, ?, ?, ?)") .bind(old_owner) .bind(name) .bind(id) .bind(now_ms()) .execute(pool) .await?; Ok(()) } /// Archive (read-only) or bring back a repository. pub async fn set_archived(pool: &Pool, id: i64, archived: bool, reason: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET archived = ?, archived_reason = ?, updated_ms = ? WHERE id = ?") .bind(archived) .bind(if archived { reason } else { "" }) .bind(now_ms()) .bind(id) .execute(pool) .await?; Ok(()) } pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> { sqlx::query("DELETE FROM repositories WHERE id = ?").bind(id).execute(pool).await?; Ok(()) } pub async fn set_description(pool: &Pool, id: i64, description: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET description = ? WHERE id = ?").bind(description).bind(id).execute(pool).await?; Ok(()) } pub async fn set_default_bookmark(pool: &Pool, id: i64, bookmark: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET default_bookmark = ? WHERE id = ?").bind(bookmark).bind(id).execute(pool).await?; Ok(()) } pub async fn set_visibility(pool: &Pool, id: i64, visibility: Visibility) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET visibility = ? WHERE id = ?").bind(visibility.as_str()).bind(id).execute(pool).await?; Ok(()) } /// Record a push's per-reference results and bump the repository's activity. pub async fn record_push(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, results: &[PushOutcome]) -> sqlx::Result<()> { let now = now_ms(); let mut tx = super::begin_write(pool).await?; for r in results { sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .bind(id) .bind(now) .bind(&r.reference) .bind(r.ok) .bind(&r.reason) .bind(&r.old_state) .bind(&r.new_state) .bind(r.heads) .bind(r.changes) .bind(pusher.map(|p| p.0).filter(|id| *id > 0)) .bind(pusher.map(|p| p.1).unwrap_or("")) .execute(&mut *tx) .await?; } if results.iter().any(|r| r.ok) { sqlx::query("UPDATE repositories SET updated_ms = ? WHERE id = ?").bind(now).bind(id).execute(&mut *tx).await?; } tx.commit().await } pub async fn pushes(pool: &Pool, id: i64, limit: i64) -> sqlx::Result> { 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 ?") .bind(id) .bind(limit) .fetch_all(pool) .await } /// Topics from user input: comma or space separated, lower-cased, /// letters, digits and dashes, at most 35 characters each and 20 in all. /// Returns the stored form or what is wrong. pub fn parse_topics(input: &str) -> Result { let mut out: Vec = Vec::new(); for t in input.split([',', ' ', '\n', '\t']).map(|t| t.trim().to_ascii_lowercase()).filter(|t| !t.is_empty()) { if t.len() > 35 || !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') || t.starts_with('-') { return Err(format!("\"{t}\" is not a topic: use letters, digits and dashes, up to 35 characters")); } if !out.contains(&t) { out.push(t); } } if out.len() > 20 { return Err("At most 20 topics.".into()); } Ok(out.join(" ")) } pub async fn set_topics(pool: &Pool, id: i64, topics: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET topics = ? WHERE id = ?").bind(topics).bind(id).execute(pool).await?; Ok(()) } /// Star or unstar; returns whether it is now starred. pub async fn toggle_star(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result { let gone = sqlx::query("DELETE FROM stars WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?; if gone.rows_affected() > 0 { return Ok(false); } sqlx::query("INSERT INTO stars (account_id, repo_id, at_ms) VALUES (?, ?, ?)") .bind(account_id) .bind(repo_id) .bind(now_ms()) .execute(pool) .await?; Ok(true) } pub async fn starred(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE account_id = ? AND repo_id = ?") .bind(account_id) .bind(repo_id) .fetch_one(pool) .await?; Ok(n > 0) } /// Stars and watchers of one repository. pub async fn counts(pool: &Pool, repo_id: i64) -> sqlx::Result<(i64, i64)> { let stars: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?; let watchers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?; Ok((stars, watchers)) } /// Star counts of every starred repository. pub async fn star_counts(pool: &Pool) -> sqlx::Result> { let rows: Vec<(i64, i64)> = sqlx::query_as("SELECT repo_id, COUNT(*) FROM stars GROUP BY repo_id").fetch_all(pool).await?; Ok(rows.into_iter().collect()) } /// Record that `id` is a fork of `from`. pub async fn set_forked_from(pool: &Pool, id: i64, from: i64) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET forked_from = ? WHERE id = ?").bind(from).bind(id).execute(pool).await?; Ok(()) } /// The forks of `id`, newest activity first. pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result> { sqlx::query_as("SELECT * FROM repositories WHERE forked_from = ? ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await } /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; Ok(()) } pub async fn set_diffusion(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET diffusion = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; Ok(()) } pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET git_mirror = ? WHERE id = ?").bind(on).bind(id).execute(pool).await?; Ok(()) } pub async fn set_git_mirror_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { sqlx::query("UPDATE repositories SET git_mirror_state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; Ok(()) } #[cfg(test)] mod topic_tests { use super::parse_topics; #[test] fn topics_are_normalised_and_checked() { assert_eq!(parse_topics("Rust, vcs retro-computing,rust").unwrap(), "rust vcs retro-computing"); assert_eq!(parse_topics("").unwrap(), ""); assert!(parse_topics("c++").is_err()); assert!(parse_topics("-lead").is_err()); assert!(parse_topics(&"x".repeat(36)).is_err()); assert!(parse_topics(&(0..21).map(|i| format!("t{i}")).collect::>().join(" ")).is_err()); } }