Skip to content

g1t/services/repos/src/about.rs

646 lines27,639 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

About: license, languages, contributors, stars, releases and activity beside the files1//! A repository's About: the answers its Files page shows beside the files
2//! (`about`), each part on its own for the API (`languages`,
3//! `contributors`, `license`), stars (`star`, `stars`, `stargazers`,
4//! `starred`) and releases (`releases`, `release`, `create_release`,
5//! `update_release`, `delete_release`). See `g1t_contracts::about`.
6//!
7//! What comes from files and history is read from `repo_stats` only
8//! (stats.rs); each answer that finds it behind the default branch's head
9//! says which repository to work out again, and `lib.rs` does so after the
10//! answer has gone (`refresh`).
11
12use std::collections::HashMap;
13
14use g1t_contracts::about::*;
15use g1t_contracts::accounts::EmailOwner;
16use g1t_contracts::identity::{UsernameArgs, UsernamesArgs};
17use g1t_contracts::repos::{Repo, is_valid_branch_name};
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
20use g1t_kit::now_ms;
21use g1t_scan::pack::write_pack;
22use serde::Deserialize;
23use worker::wasm_bindgen::JsValue;
24use worker::{D1Database, Env, Result};
25
26use crate::registry::{can_write, store_key};
27use crate::store::{GitRepo, GitStore, Scope};
28use crate::{Repos, UNVERIFIED, land, not_found, stats};
29
30/// What an answer from `repo_stats` leaves to do: the repository to work
31/// out again, when what is kept is behind its head.
32pub(crate) type Refresh = Option<Repo>;
33
34/// Releases a list holds at most.
35const MAX_RELEASES: u32 = 100;
36/// Stargazers a page holds.
37const STARGAZERS_PAGE: u32 = 100;
38
39#[derive(Debug, Deserialize)]
40struct ReleaseRow {
41 id: String,
42 tag_name: String,
43 target: String,
44 name: Option<String>,
45 body: String,
46 draft: f64,
47 prerelease: f64,
48 author: Option<String>,
49 created_at: String,
50 published_at: Option<String>,
51}
52
53impl From<ReleaseRow> for Release {
54 fn from(row: ReleaseRow) -> Self {
55 Release {
56 id: row.id,
57 tag_name: row.tag_name,
58 target: row.target,
59 name: row.name,
60 body: row.body,
61 draft: row.draft != 0.0,
62 prerelease: row.prerelease != 0.0,
63 author: row.author,
64 created_at: row.created_at,
65 published_at: row.published_at,
66 latest: false,
67 }
68 }
69}
70
71const RELEASE_COLUMNS: &str = "id, tag_name, target, name, body, draft, prerelease, author, created_at, published_at";
72
73/// The latest release's id: the newest published one that is neither a
74/// draft nor a prerelease.
75async fn latest_id(db: &D1Database, repo_id: &str) -> Result<Option<String>> {
76 #[derive(Deserialize)]
77 struct Row {
78 id: String,
79 }
80 Ok(db
81 .prepare(
82 "SELECT id FROM releases WHERE repo_id = ? AND draft = 0 AND prerelease = 0 AND published_at IS NOT NULL
83 ORDER BY published_at DESC, created_at DESC LIMIT 1",
84 )
85 .bind(&[repo_id.into()])?
86 .first::<Row>(None)
87 .await?
88 .map(|row| row.id))
89}
90
91fn marked(mut release: Release, latest: Option<&str>) -> Release {
92 release.latest = latest == Some(release.id.as_str());
93 release
94}
95
96/// A release's title: trimmed, empty as none.
97fn title(name: Option<&str>) -> Option<String> {
98 name.map(str::trim).filter(|name| !name.is_empty()).map(str::to_owned)
99}
100
101fn check_release_text(name: Option<&str>, body: Option<&str>) -> std::result::Result<(), String> {
102 if name.is_some_and(|name| name.chars().count() > MAX_RELEASE_NAME_CHARS) {
103 return Err(format!("A release's title is at most {MAX_RELEASE_NAME_CHARS} characters."));
104 }
105 if body.is_some_and(|body| body.chars().count() > MAX_RELEASE_BODY_CHARS) {
106 return Err(format!("A release's notes are at most {MAX_RELEASE_BODY_CHARS} characters."));
107 }
108 Ok(())
109}
110
111impl<S: GitStore> Repos<S> {
112 fn db(&self) -> &D1Database {
113 &self.registry.db
114 }
115
116 /// The default branch's head, or None for an empty repository.
117 async fn head_of(&self, repo: &Repo) -> Result<Option<String>> {
118 let git = self.read_git(repo).await?;
119 Ok(git.log(&repo.default_branch, 1).await?.into_iter().next().map(|commit| commit.hash))
120 }
121
122 /// What is kept, the head now, and whether to work it out again.
123 async fn kept_for(&self, repo: &Repo) -> Result<(stats::Kept, Option<String>, Refresh)> {
124 let (kept, head) = futures_util::future::try_join(stats::kept(self.db(), &repo.id), self.head_of(repo)).await?;
125 let refresh = (stats::has_about(repo) && kept.wants_run(head.as_deref(), now_ms())).then(|| repo.clone());
126 Ok((kept, head, refresh))
127 }
128
129 /// How many starred it, and whether the viewer did.
130 async fn star_count(&self, repo_id: &str, viewer: &Viewer) -> Result<Stars> {
131 #[derive(Deserialize)]
132 struct Row {
133 stars: f64,
134 starred: f64,
135 }
136 let user = viewer.as_ref().map(|user| user.id.as_str()).unwrap_or("");
137 let row = self
138 .db()
139 .prepare(
140 "SELECT (SELECT COUNT(*) FROM repo_stars WHERE repo_id = ?1) AS stars,
141 EXISTS (SELECT 1 FROM repo_stars WHERE repo_id = ?1 AND user_id = ?2) AS starred",
142 )
143 .bind(&[repo_id.into(), user.into()])?
144 .first::<Row>(None)
145 .await?;
146 Ok(row.map_or_else(Stars::default, |row| Stars { stars: row.stars as u64, starred: row.starred != 0.0 }))
147 }
148
149 /// The releases the viewer may see, newest first: drafts only for those
150 /// who can push.
151 async fn visible_releases(&self, repo: &Repo, viewer: &Viewer, limit: u32) -> Result<Vec<Release>> {
152 let drafts = can_write(repo, viewer);
153 let sql = format!(
154 "SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = ?1 {} ORDER BY COALESCE(published_at, created_at) DESC, created_at DESC LIMIT ?2",
155 if drafts { "" } else { "AND draft = 0" }
156 );
157 let rows = self
158 .db()
159 .prepare(sql)
160 .bind(&[repo.id.as_str().into(), JsValue::from_f64(f64::from(limit))])?
161 .all()
162 .await?
163 .results::<ReleaseRow>()?;
164 let latest = latest_id(self.db(), &repo.id).await?;
165 Ok(rows.into_iter().map(|row| marked(row.into(), latest.as_deref())).collect())
166 }
167
168 /// `about`: everything the Files page's About shows, in one answer.
169 pub(crate) async fn about(&self, a: RepoViewArgs) -> Result<(Outcome<RepoAbout>, Refresh)> {
170 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
171 return Ok((not_found(), None));
172 };
173 let releases_count = async {
174 #[derive(Deserialize)]
175 struct Row {
176 count: f64,
177 }
178 let sql = if can_write(&repo, &a.viewer) {
179 "SELECT COUNT(*) AS count FROM releases WHERE repo_id = ?"
180 } else {
181 "SELECT COUNT(*) AS count FROM releases WHERE repo_id = ? AND draft = 0"
182 };
183 Ok::<_, worker::Error>(self.db().prepare(sql).bind(&[repo.id.as_str().into()])?.first::<Row>(None).await?.map_or(0, |row| row.count as u64))
184 };
185 let latest = async {
186 let Some(id) = latest_id(self.db(), &repo.id).await? else { return Ok::<_, worker::Error>(None) };
187 let row = self
188 .db()
189 .prepare(format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE id = ?"))
190 .bind(&[id.as_str().into()])?
191 .first::<ReleaseRow>(None)
192 .await?;
193 Ok(row.map(|row| Release { latest: true, ..row.into() }))
194 };
195 let ((kept, head, refresh), stars, releases, latest_release) =
196 futures_util::future::try_join4(self.kept_for(&repo), self.star_count(&repo.id, &a.viewer), releases_count, latest).await?;
197 Ok((
198 Outcome::Ok(RepoAbout {
199 freshness: kept.freshness(head),
200 license: kept.license(),
201 security_policy: kept.security_policy.clone(),
202 languages: kept.languages(),
203 contributors: kept.contributors_total as u32,
204 top_contributors: kept.top_contributors(),
205 stars: stars.stars,
206 starred: stars.starred,
207 releases,
208 latest_release,
209 }),
210 refresh,
211 ))
212 }
213
214 /// `languages`.
215 pub(crate) async fn languages(&self, a: RepoViewArgs) -> Result<(Outcome<Languages>, Refresh)> {
216 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
217 return Ok((not_found(), None));
218 };
219 let (kept, head, refresh) = self.kept_for(&repo).await?;
220 Ok((Outcome::Ok(stats::languages_of(&kept, head)), refresh))
221 }
222
223 /// `license`.
224 pub(crate) async fn license(&self, a: RepoViewArgs) -> Result<(Outcome<Option<License>>, Refresh)> {
225 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
226 return Ok((not_found(), None));
227 };
228 let (kept, _, refresh) = self.kept_for(&repo).await?;
229 Ok((Outcome::Ok(kept.license()), refresh))
230 }
231
232 /// `contributors`.
233 pub(crate) async fn contributors(&self, a: RepoViewArgs) -> Result<(Outcome<Contributors>, Refresh)> {
234 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
235 return Ok((not_found(), None));
236 };
237 let (kept, head, refresh) = self.kept_for(&repo).await?;
238 let mut answer = stats::contributors(self.db(), &repo.id, head.clone()).await?;
239 answer.freshness = kept.freshness(head);
240 Ok((Outcome::Ok(answer), refresh))
241 }
242
243 /// Works out `repo`'s About again, if no one else is. In the
244 /// background: a failure is logged and the lease let go early.
245 pub(crate) async fn refresh(&self, repo: &Repo) {
246 let db = self.db();
247 match stats::claim(db, &repo.id, now_ms()).await {
248 Ok(true) => {}
249 Ok(false) => return,
250 Err(error) => {
251 worker::console_error!("the About of {} was not worked out: {error}", repo.id);
252 return;
253 }
254 }
255 let worked = async {
256 let git = self.store.open(&store_key(repo)).await?;
257 let Some(head) = git.log(&repo.default_branch, 1).await?.into_iter().next() else {
258 return Ok(None);
259 };
260 stats::work_out(&git, &head.hash, &head.tree_hash, self.identity.as_ref()).await.map(Some)
261 };
262 match worked.await {
263 Ok(Some(worked)) => {
264 if let Err(error) = stats::keep(db, &repo.id, &worked).await {
265 worker::console_error!("the About of {} was worked out but not kept: {error}", repo.id);
266 stats::release(db, &repo.id).await;
267 }
268 }
269 Ok(None) => stats::release(db, &repo.id).await,
270 Err(error) => {
271 worker::console_error!("the About of {} was not worked out: {error}", repo.id);
272 stats::release(db, &repo.id).await;
273 }
274 }
275 }
276
277 /// `stars`.
278 pub(crate) async fn stars(&self, a: RepoViewArgs) -> Result<Outcome<Stars>> {
279 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
280 return Ok(not_found());
281 };
282 Ok(Outcome::Ok(self.star_count(&repo.id, &a.viewer).await?))
283 }
284
285 /// `star`: anyone signed in who can read the repository stars it.
286 pub(crate) async fn star(&self, a: StarArgs) -> Result<Outcome<Stars>> {
287 let viewer = Some(a.actor.clone());
288 let Some(repo) = self.readable(&a.path, &viewer).await? else {
289 return Ok(not_found());
290 };
291 if repo.fork_of.is_some() {
292 return Ok(not_found());
293 }
294 if a.actor.kind != g1t_contracts::PrincipalKind::User {
295 return Ok(Outcome::fail(FailureCode::Forbidden, "Only people star repositories."));
296 }
297 if a.starred {
298 self.db()
299 .prepare("INSERT OR IGNORE INTO repo_stars (repo_id, user_id, created_at) VALUES (?, ?, ?)")
300 .bind(&[repo.id.as_str().into(), a.actor.id.as_str().into(), rfc3339(now_ms()).into()])?
301 .run()
302 .await?;
303 } else {
304 self.db()
305 .prepare("DELETE FROM repo_stars WHERE repo_id = ? AND user_id = ?")
306 .bind(&[repo.id.as_str().into(), a.actor.id.as_str().into()])?
307 .run()
308 .await?;
309 }
310 Ok(Outcome::Ok(self.star_count(&repo.id, &viewer).await?))
311 }
312
313 /// The accounts behind user ids, by id.
314 async fn accounts(&self, ids: Vec<String>) -> HashMap<String, EmailOwner> {
315 let Some(identity) = &self.identity else { return HashMap::new() };
316 if ids.is_empty() {
317 return HashMap::new();
318 }
319 g1t_kit::call(identity, "accounts", &UsernamesArgs { ids }).await.unwrap_or_else(|error| {
320 worker::console_error!("stargazers' accounts not read: {error}");
321 HashMap::new()
322 })
323 }
324
325 /// `stargazers`.
326 pub(crate) async fn stargazers(&self, a: StargazersArgs) -> Result<Outcome<Vec<Stargazer>>> {
327 #[derive(Deserialize)]
328 struct Row {
329 user_id: String,
330 created_at: String,
331 }
332 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
333 return Ok(not_found());
334 };
335 let page = a.page.unwrap_or(1).max(1);
336 let rows = self
337 .db()
338 .prepare("SELECT user_id, created_at FROM repo_stars WHERE repo_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?")
339 .bind(&[
340 repo.id.as_str().into(),
341 JsValue::from_f64(f64::from(STARGAZERS_PAGE)),
342 JsValue::from_f64(f64::from((page - 1) * STARGAZERS_PAGE)),
343 ])?
344 .all()
345 .await?
346 .results::<Row>()?;
347 let accounts = self.accounts(rows.iter().map(|row| row.user_id.clone()).collect()).await;
348 Ok(Outcome::Ok(
349 rows.into_iter()
350 .filter_map(|row| {
351 let account = accounts.get(&row.user_id)?;
352 Some(Stargazer { username: account.username.clone(), avatar: account.avatar.clone(), starred_at: row.created_at })
353 })
354 .collect(),
355 ))
356 }
357
358 /// `starred`: by username, what the viewer may see of it.
359 pub(crate) async fn starred(&self, a: StarredArgs) -> Result<Vec<StarredRepo>> {
360 #[derive(Deserialize)]
361 struct Row {
362 repo_id: String,
363 created_at: String,
364 stars: f64,
365 }
366 let Some(identity) = &self.identity else { return Ok(Vec::new()) };
367 let person: Viewer = g1t_kit::call(identity, "user_by_username", &UsernameArgs { username: a.username.clone() }).await?;
368 let Some(person) = person else { return Ok(Vec::new()) };
369 let rows = self
370 .db()
371 .prepare(
372 "SELECT s.repo_id, s.created_at, (SELECT COUNT(*) FROM repo_stars t WHERE t.repo_id = s.repo_id) AS stars
373 FROM repo_stars s WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT 100",
374 )
375 .bind(&[person.id.as_str().into()])?
376 .all()
377 .await?
378 .results::<Row>()?;
379 let ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect();
380 let readable: HashMap<String, Repo> = self.registry.readable(&ids, &a.viewer).await?.into_iter().map(|repo| (repo.id.clone(), repo)).collect();
381 Ok(rows
382 .into_iter()
383 .filter_map(|row| {
384 let repo = readable.get(&row.repo_id)?.clone();
385 Some(StarredRepo { repo, starred_at: row.created_at, stars: row.stars as u64 })
386 })
387 .collect())
388 }
389
390 /// `releases`.
391 pub(crate) async fn releases(&self, a: RepoViewArgs) -> Result<Outcome<Vec<Release>>> {
392 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
393 return Ok(not_found());
394 };
395 Ok(Outcome::Ok(self.visible_releases(&repo, &a.viewer, MAX_RELEASES).await?))
396 }
397
398 /// One release row of a repository, by id or tag.
399 async fn release_row(&self, repo_id: &str, id: Option<&str>, tag: Option<&str>) -> Result<Option<Release>> {
400 let (column, value) = match (id, tag) {
401 (Some(id), _) => ("id", id),
402 (None, Some(tag)) => ("tag_name", tag),
403 (None, None) => return Ok(None),
404 };
405 Ok(self
406 .db()
407 .prepare(format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = ? AND {column} = ?"))
408 .bind(&[repo_id.into(), value.into()])?
409 .first::<ReleaseRow>(None)
410 .await?
411 .map(Release::from))
412 }
413
414 /// `release`.
415 pub(crate) async fn release(&self, a: ReleaseArgs) -> Result<Outcome<Release>> {
416 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
417 return Ok(not_found());
418 };
419 let latest = latest_id(self.db(), &repo.id).await?;
420 let id = if a.latest { latest.clone() } else { a.id.clone() };
421 let found = self.release_row(&repo.id, id.as_deref(), if a.latest { None } else { a.tag.as_deref() }).await?;
422 Ok(match found {
423 Some(release) if !release.draft || can_write(&repo, &a.viewer) => Outcome::Ok(marked(release, latest.as_deref())),
424 _ => Outcome::fail(FailureCode::NotFound, "No such release."),
425 })
426 }
427
428 /// The repository, if the actor may make and change its releases.
429 async fn writable_for_releases(&self, path: &g1t_contracts::repos::RepoPath, actor: &User) -> Result<std::result::Result<Repo, Outcome<()>>> {
430 let viewer = Some(actor.clone());
431 let Some(repo) = self.readable(path, &viewer).await? else {
432 return Ok(Err(not_found()));
433 };
434 if !can_write(&repo, &viewer) {
435 return Ok(Err(Outcome::fail(
436 FailureCode::Forbidden,
437 g1t_contracts::access::needs(g1t_contracts::access::Capability::Push, &format!("{}/{}", repo.namespace, repo.name)),
438 )));
439 }
440 if !actor.verified {
441 return Ok(Err(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)));
442 }
443 if let Some((code, message)) = crate::lifecycle::archived_refusal(&repo) {
444 return Ok(Err(Outcome::fail(code, message)));
445 }
446 Ok(Ok(repo))
447 }
448
449 /// `create_release`.
450 pub(crate) async fn create_release(&self, a: CreateReleaseArgs) -> Result<Outcome<Release>> {
451 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
452 Ok(repo) => repo,
453 Err(refused) => return Ok(refused.retype()),
454 };
455 let tag = a.tag_name.trim().trim_start_matches("refs/tags/").to_owned();
456 if !is_valid_branch_name(&tag) {
457 return Ok(Outcome::fail(FailureCode::Invalid, format!("{tag:?} cannot be a tag's name.")));
458 }
459 if let Err(message) = check_release_text(a.name.as_deref(), a.body.as_deref()) {
460 return Ok(Outcome::fail(FailureCode::Invalid, message));
461 }
462 if self.release_row(&repo.id, None, Some(&tag)).await?.is_some() {
463 return Ok(Outcome::fail(FailureCode::Conflict, format!("{tag} already has a release: change that one instead.")));
464 }
465 let git = self.store.open(&store_key(&repo)).await?;
466 let access = git.access(Scope::Read).await?;
467 let refs = crate::refs::heads_and_tags(crate::refs::all(&access).await?);
468 let existing = refs.iter().find(|(name, _)| *name == format!("refs/tags/{tag}")).map(|(_, hash)| hash.clone());
469 let target = match existing {
470 Some(hash) => match git.log(&hash, 1).await?.into_iter().next() {
471 Some(commit) => commit.hash,
472 None => hash,
473 },
474 None => {
475 // A new tag, made at the target.
476 let from = a.target.as_deref().map(str::trim).filter(|target| !target.is_empty()).unwrap_or(&repo.default_branch);
477 let Some(commit) = git.log(from, 1).await?.into_iter().next() else {
478 return Ok(Outcome::fail(FailureCode::NotFound, format!("{from} is not a branch or commit of this repository.")));
479 };
480 let git_ref = format!("refs/tags/{tag}");
481 let change = g1t_rules::push::RefChange {
482 git_ref: git_ref.clone(),
483 old: None,
484 new: Some(commit.hash.clone()),
485 fast_forward: None,
486 commits: Vec::new(),
487 complete: true,
488 };
489 if let crate::rules::Ruled::Refused { message } =
490 self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::CreateRef, vec![change]).await?
491 {
492 return Ok(Outcome::fail(FailureCode::Forbidden, message));
493 }
494 let write = git.access(Scope::Write).await?;
495 // The commit is there already: the pack is empty.
496 if let Err(reason) = land::push_ref(&write, &git_ref, None, &commit.hash, Some(write_pack(&[]))).await? {
497 return Ok(Outcome::fail(FailureCode::Conflict, format!("The tag {tag} could not be made: {reason}")));
498 }
499 self.refs_moved(&repo.id).await;
500 self.publish_push(&repo, &git_ref, None, &commit.hash, Some(a.actor.id.clone())).await?;
501 commit.hash
502 }
503 };
504 let now = rfc3339(now_ms());
505 let id = new_id("rel", now_ms());
506 let published: JsValue = if a.draft { JsValue::NULL } else { now.as_str().into() };
507 self.db()
508 .prepare(
509 "INSERT INTO releases (id, repo_id, tag_name, target, name, body, draft, prerelease, author_id, author, created_at, published_at, updated_at)
510 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?11)",
511 )
512 .bind(&[
513 id.as_str().into(),
514 repo.id.as_str().into(),
515 tag.as_str().into(),
516 target.as_str().into(),
517 title(a.name.as_deref()).map_or(JsValue::NULL, |name| name.into()),
518 a.body.unwrap_or_default().into(),
519 JsValue::from_f64(if a.draft { 1.0 } else { 0.0 }),
520 JsValue::from_f64(if a.prerelease { 1.0 } else { 0.0 }),
521 a.actor.id.as_str().into(),
522 a.actor.username.as_str().into(),
523 now.as_str().into(),
524 published,
525 ])?
526 .run()
527 .await?;
528 self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor), id: Some(id), tag: None, latest: false }).await
529 }
530
531 /// `update_release`.
532 pub(crate) async fn update_release(&self, a: UpdateReleaseArgs) -> Result<Outcome<Release>> {
533 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
534 Ok(repo) => repo,
535 Err(refused) => return Ok(refused.retype()),
536 };
537 let Some(release) = self.release_row(&repo.id, Some(&a.id), None).await? else {
538 return Ok(Outcome::fail(FailureCode::NotFound, "No such release."));
539 };
540 if let Err(message) = check_release_text(a.name.as_deref(), a.body.as_deref()) {
541 return Ok(Outcome::fail(FailureCode::Invalid, message));
542 }
543 let now = rfc3339(now_ms());
544 let name = match &a.name {
545 Some(name) => title(Some(name)),
546 None => release.name.clone(),
547 };
548 let draft = a.draft.unwrap_or(release.draft);
549 // Published the first time it stops being a draft; a release made a
550 // draft again keeps no date.
551 let published_at = if draft { None } else { release.published_at.clone().or(Some(now.clone())) };
552 self.db()
553 .prepare("UPDATE releases SET name = ?2, body = ?3, draft = ?4, prerelease = ?5, published_at = ?6, updated_at = ?7 WHERE id = ?1")
554 .bind(&[
555 release.id.as_str().into(),
556 name.map_or(JsValue::NULL, |name| name.into()),
557 a.body.unwrap_or(release.body).into(),
558 JsValue::from_f64(if draft { 1.0 } else { 0.0 }),
559 JsValue::from_f64(if a.prerelease.unwrap_or(release.prerelease) { 1.0 } else { 0.0 }),
560 published_at.map_or(JsValue::NULL, |at| at.into()),
561 now.into(),
562 ])?
563 .run()
564 .await?;
565 self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor), id: Some(release.id), tag: None, latest: false }).await
566 }
567
568 /// `delete_release`: the tag stays.
569 pub(crate) async fn delete_release(&self, a: DeleteReleaseArgs) -> Result<Outcome<bool>> {
570 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
571 Ok(repo) => repo,
572 Err(refused) => return Ok(refused.retype()),
573 };
574 let deleted = self
575 .db()
576 .prepare("DELETE FROM releases WHERE repo_id = ? AND id = ? RETURNING id")
577 .bind(&[repo.id.as_str().into(), a.id.as_str().into()])?
578 .first::<serde_json::Value>(None)
579 .await?;
580 Ok(match deleted {
581 Some(_) => Outcome::Ok(true),
582 None => Outcome::fail(FailureCode::NotFound, "No such release."),
583 })
584 }
585}
586
587/// A refusal of one type, as another: it carries no value.
588trait Retype {
589 fn retype<T>(self) -> Outcome<T>;
590}
591
592impl Retype for Outcome<()> {
593 fn retype<T>(self) -> Outcome<T> {
594 match self {
595 Outcome::Fail(failure) => Outcome::Fail(failure),
596 Outcome::Ok(()) => Outcome::fail(FailureCode::Invalid, "Nothing to do."),
597 }
598 }
599}
600
601/// Works out `repo`'s About after the answer has gone, when an answer
602/// found it behind.
603pub(crate) fn refresh_later(env: &Env, ctx: &worker::Context, refresh: Refresh) {
604 let Some(repo) = refresh else { return };
605 let env = env.clone();
606 ctx.wait_until(async move {
607 match crate::service(&env) {
608 Ok(repos) => repos.refresh(&repo).await,
609 Err(error) => worker::console_error!("the About of {} was not worked out: {error}", repo.id),
610 }
611 });
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn release_titles_are_tidied_and_text_is_bounded() {
620 assert_eq!(title(Some(" v1.0 ")).as_deref(), Some("v1.0"));
621 assert_eq!(title(Some(" ")), None);
622 assert_eq!(title(None), None);
623 assert!(check_release_text(Some("ok"), Some("notes")).is_ok());
624 assert!(check_release_text(Some(&"x".repeat(MAX_RELEASE_NAME_CHARS + 1)), None).is_err());
625 assert!(check_release_text(None, Some(&"x".repeat(MAX_RELEASE_BODY_CHARS + 1))).is_err());
626 }
627
628 #[test]
629 fn only_the_latest_is_marked() {
630 let release = Release {
631 id: "rel_1".into(),
632 tag_name: "v1".into(),
633 target: "c".into(),
634 name: None,
635 body: String::new(),
636 draft: false,
637 prerelease: false,
638 author: None,
639 created_at: String::new(),
640 published_at: None,
641 latest: false,
642 };
643 assert!(marked(release.clone(), Some("rel_1")).latest);
644 assert!(!marked(release, Some("rel_2")).latest);
645 }
646}