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