Skip to main content

max / makenotwork

10.0 KB · 338 lines History Blame Raw
1 //! Git repository CRUD and lookup queries.
2
3 use chrono::{DateTime, Utc};
4 use sqlx::{FromRow, PgPool};
5
6 use super::models::DbGitRepo;
7 use super::{GitRepoId, GitRepoKind, ProjectId, UserId, Visibility};
8 use crate::error::Result;
9
10 /// A public repo joined with its owner's username, for the explore page.
11 #[derive(FromRow)]
12 pub struct PublicRepoWithOwner {
13 pub name: String,
14 pub description: String,
15 pub created_at: DateTime<Utc>,
16 pub owner_username: String,
17 }
18
19 /// Register a new git repository for a user (default visibility: public).
20 ///
21 /// Validates `name` against `validate_git_repo_name` as a defense-in-depth
22 /// backstop, the SSH dispatch path and HTTP smart-protocol path both call
23 /// this with names supplied by untrusted remote git clients.
24 #[tracing::instrument(skip_all)]
25 pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> {
26 crate::validation::validate_creatable_repo_name(name)?;
27 let repo = sqlx::query_as::<_, DbGitRepo>(
28 r"
29 INSERT INTO git_repos (user_id, name)
30 VALUES ($1, $2)
31 RETURNING *
32 ",
33 )
34 .bind(user_id)
35 .bind(name)
36 .fetch_one(pool)
37 .await?;
38
39 Ok(repo)
40 }
41
42 /// Register a new git repository with explicit visibility.
43 #[tracing::instrument(skip_all)]
44 pub async fn create_repo_with_visibility(
45 pool: &PgPool,
46 user_id: UserId,
47 name: &str,
48 visibility: Visibility,
49 ) -> Result<DbGitRepo> {
50 crate::validation::validate_creatable_repo_name(name)?;
51 let repo = sqlx::query_as::<_, DbGitRepo>(
52 r"
53 INSERT INTO git_repos (user_id, name, visibility)
54 VALUES ($1, $2, $3)
55 RETURNING *
56 ",
57 )
58 .bind(user_id)
59 .bind(name)
60 .bind(visibility)
61 .fetch_one(pool)
62 .await?;
63
64 Ok(repo)
65 }
66
67 /// The account's annotation repository, if it has one yet.
68 #[tracing::instrument(skip_all)]
69 pub async fn get_annotation_repo(pool: &PgPool, user_id: UserId) -> Result<Option<DbGitRepo>> {
70 let repo = sqlx::query_as::<_, DbGitRepo>(
71 "SELECT * FROM git_repos WHERE user_id = $1 AND kind = 'annotations'",
72 )
73 .bind(user_id)
74 .fetch_optional(pool)
75 .await?;
76
77 Ok(repo)
78 }
79
80 /// Register the account's annotation repository.
81 ///
82 /// Private and `kind = 'annotations'` in one statement rather than an insert
83 /// plus two updates: a row that is public for the width of a transaction is a
84 /// row that can be read. `ON CONFLICT DO NOTHING` plus a re-select, so two
85 /// first-annotations racing each other both end up with the row the winner
86 /// made.
87 ///
88 /// Deliberately does not call `validate_creatable_repo_name`: the name it
89 /// writes is the reserved one, and this is the code the reservation exists for.
90 #[tracing::instrument(skip_all)]
91 pub async fn create_annotation_repo(pool: &PgPool, user_id: UserId) -> Result<DbGitRepo> {
92 let inserted = sqlx::query_as::<_, DbGitRepo>(
93 r"
94 INSERT INTO git_repos (user_id, name, visibility, kind, description)
95 VALUES ($1, $2, 'private', 'annotations', $3)
96 ON CONFLICT (user_id, name) DO NOTHING
97 RETURNING *
98 ",
99 )
100 .bind(user_id)
101 .bind(crate::constants::ANNOTATION_REPO_NAME)
102 .bind(ANNOTATION_REPO_DESCRIPTION)
103 .fetch_optional(pool)
104 .await?;
105
106 if let Some(repo) = inserted {
107 return Ok(repo);
108 }
109
110 // Somebody else inserted it, or the account already owns a repository of
111 // that name. Either way the row that is there is the answer.
112 get_repo_by_user_and_name(pool, user_id, crate::constants::ANNOTATION_REPO_NAME)
113 .await?
114 .ok_or_else(|| {
115 crate::error::AppError::Internal(anyhow::anyhow!(
116 "annotation repo insert conflicted but no row is there"
117 ))
118 })
119 }
120
121 /// What the annotation repository says on its own page.
122 pub const ANNOTATION_REPO_DESCRIPTION: &str =
123 "Personal annotations. Notes on commits across makenot.work, private to this account.";
124
125 /// Whether this repository is the account's annotation store, which is private
126 /// permanently: publishing a set of annotations is a moderation and consent
127 /// decision, not a toggle.
128 pub fn is_annotation_repo(repo: &DbGitRepo) -> bool {
129 repo.kind == GitRepoKind::Annotations
130 }
131
132 /// Look up a repo by its primary key. Returns `None` if not found.
133 #[tracing::instrument(skip_all)]
134 pub async fn get_repo_by_id(pool: &PgPool, repo_id: GitRepoId) -> Result<Option<DbGitRepo>> {
135 let repo = sqlx::query_as::<_, DbGitRepo>("SELECT * FROM git_repos WHERE id = $1")
136 .bind(repo_id)
137 .fetch_optional(pool)
138 .await?;
139
140 Ok(repo)
141 }
142
143 /// Look up a repo by its owning user and bare name. Returns `None` if not found.
144 #[tracing::instrument(skip_all)]
145 pub async fn get_repo_by_user_and_name(
146 pool: &PgPool,
147 user_id: UserId,
148 name: &str,
149 ) -> Result<Option<DbGitRepo>> {
150 let repo =
151 sqlx::query_as::<_, DbGitRepo>("SELECT * FROM git_repos WHERE user_id = $1 AND name = $2")
152 .bind(user_id)
153 .bind(name)
154 .fetch_optional(pool)
155 .await?;
156
157 Ok(repo)
158 }
159
160 /// List all repos owned by a user, newest first.
161 #[tracing::instrument(skip_all)]
162 pub async fn get_repos_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitRepo>> {
163 let repos = sqlx::query_as::<_, DbGitRepo>(
164 "SELECT * FROM git_repos WHERE user_id = $1 ORDER BY created_at DESC LIMIT 500",
165 )
166 .bind(user_id)
167 .fetch_all(pool)
168 .await?;
169
170 Ok(repos)
171 }
172
173 /// List public repos owned by a user, newest first.
174 #[tracing::instrument(skip_all)]
175 pub async fn get_public_repos_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitRepo>> {
176 let repos = sqlx::query_as::<_, DbGitRepo>(
177 "SELECT * FROM git_repos WHERE user_id = $1 AND visibility = 'public' ORDER BY created_at DESC LIMIT 500",
178 )
179 .bind(user_id)
180 .fetch_all(pool)
181 .await?;
182
183 Ok(repos)
184 }
185
186 /// List all repos linked to a specific project.
187 #[tracing::instrument(skip_all)]
188 pub async fn get_repos_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbGitRepo>> {
189 let repos = sqlx::query_as::<_, DbGitRepo>(
190 "SELECT * FROM git_repos WHERE project_id = $1 ORDER BY name ASC",
191 )
192 .bind(project_id)
193 .fetch_all(pool)
194 .await?;
195
196 Ok(repos)
197 }
198
199 /// Link a repo to a project (sets `project_id`).
200 #[tracing::instrument(skip_all)]
201 pub async fn link_repo_to_project(
202 pool: &PgPool,
203 repo_id: GitRepoId,
204 project_id: ProjectId,
205 ) -> Result<()> {
206 sqlx::query("UPDATE git_repos SET project_id = $2 WHERE id = $1")
207 .bind(repo_id)
208 .bind(project_id)
209 .execute(pool)
210 .await?;
211
212 Ok(())
213 }
214
215 /// Unlink a repo from its project (sets `project_id = NULL`).
216 #[tracing::instrument(skip_all)]
217 pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Result<()> {
218 sqlx::query("UPDATE git_repos SET project_id = NULL WHERE id = $1")
219 .bind(repo_id)
220 .execute(pool)
221 .await?;
222
223 Ok(())
224 }
225
226 /// Update the visibility of a repo.
227 #[tracing::instrument(skip_all)]
228 pub async fn update_visibility(
229 pool: &PgPool,
230 repo_id: GitRepoId,
231 visibility: Visibility,
232 ) -> Result<()> {
233 sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1")
234 .bind(repo_id)
235 .bind(visibility)
236 .execute(pool)
237 .await?;
238
239 Ok(())
240 }
241
242 /// Update description and visibility in one call.
243 #[tracing::instrument(skip_all)]
244 pub async fn update_repo_settings(
245 pool: &PgPool,
246 repo_id: GitRepoId,
247 description: &str,
248 visibility: Visibility,
249 ) -> Result<()> {
250 sqlx::query("UPDATE git_repos SET description = $2, visibility = $3 WHERE id = $1")
251 .bind(repo_id)
252 .bind(description)
253 .bind(visibility)
254 .execute(pool)
255 .await?;
256
257 Ok(())
258 }
259
260 /// Delete a repo from the database (leaves files on disk for safety).
261 #[tracing::instrument(skip_all)]
262 pub async fn delete_repo(pool: &PgPool, repo_id: GitRepoId) -> Result<()> {
263 sqlx::query("DELETE FROM git_repos WHERE id = $1")
264 .bind(repo_id)
265 .execute(pool)
266 .await?;
267
268 Ok(())
269 }
270
271 /// List all public repos across all users, newest first, with owner username.
272 #[tracing::instrument(skip_all)]
273 pub async fn get_all_public_repos(
274 pool: &PgPool,
275 limit: i64,
276 offset: i64,
277 ) -> Result<Vec<PublicRepoWithOwner>> {
278 let repos = sqlx::query_as::<_, PublicRepoWithOwner>(
279 r"
280 SELECT g.name, g.description, g.created_at, u.username AS owner_username
281 FROM git_repos g
282 JOIN users u ON u.id = g.user_id
283 WHERE g.visibility = 'public'
284 ORDER BY g.created_at DESC
285 LIMIT $1 OFFSET $2
286 ",
287 )
288 .bind(limit)
289 .bind(offset)
290 .fetch_all(pool)
291 .await?;
292
293 Ok(repos)
294 }
295
296 /// Count all public repos (for pagination).
297 #[tracing::instrument(skip_all)]
298 pub async fn count_all_public_repos(pool: &PgPool) -> Result<i64> {
299 let count: (i64,) =
300 sqlx::query_as("SELECT COUNT(*) FROM git_repos WHERE visibility = 'public'")
301 .fetch_one(pool)
302 .await?;
303
304 Ok(count.0)
305 }
306
307 /// Every repository on the platform, with its owner's username.
308 ///
309 /// Visibility-blind on purpose: the caller is an operator rebuilding a
310 /// server-side projection of what is on disk, not a page deciding what to show.
311 /// Nothing user-facing should reach for this.
312 #[tracing::instrument(skip_all)]
313 pub async fn all_repos_with_owner(pool: &PgPool) -> Result<Vec<(GitRepoId, String, String)>> {
314 let rows = sqlx::query_as::<_, (GitRepoId, String, String)>(
315 r"
316 SELECT g.id, u.username, g.name
317 FROM git_repos g
318 JOIN users u ON u.id = g.user_id
319 ORDER BY u.username, g.name
320 ",
321 )
322 .fetch_all(pool)
323 .await?;
324
325 Ok(rows)
326 }
327
328 #[cfg(test)]
329 mod tests {
330 #[test]
331 fn annotation_repo_name_is_a_legal_repo_name_and_reserved() {
332 let name = crate::constants::ANNOTATION_REPO_NAME;
333 assert!(crate::validation::validate_git_repo_name(name).is_ok());
334 assert!(crate::validation::is_reserved_repo_name(name));
335 assert!(crate::validation::validate_creatable_repo_name(name).is_err());
336 }
337 }
338