Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
724 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, reading for
245 /// `budget_ms` at most. In the background: a failure is logged and the
246 /// lease let go early. A run that was slow or partial is logged with how
247 /// long it took (`about_run`), as a slow RPC answer is.
248 pub(crate) async fn refresh(&self, repo: &Repo, budget_ms: u64) {
249 let db = self.db();
250 let started = now_ms();
251 match stats::claim(db, &repo.id, now_ms()).await {
252 Ok(true) => {}
253 Ok(false) => return,
254 Err(error) => {
255 worker::console_error!("the About of {} was not worked out: {error}", repo.id);
256 return;
257 }
258 }
259 let worked = async {
260 let git = self.store.open(&store_key(repo)).await?;
261 let Some(head) = git.log(&repo.default_branch, 1).await?.into_iter().next() else {
262 return Ok(None);
263 };
264 stats::work_out(&git, &head.hash, &head.tree_hash, self.identity.as_ref(), budget_ms).await.map(Some)
265 };
266 match worked.await {
267 Ok(Some(worked)) => {
268 let took = now_ms().saturating_sub(started);
269 if worked.partial || took >= SLOW_RUN_MS {
270 worker::console_log!(
271 "{}",
272 serde_json::json!({ "about_run": repo.id, "took_ms": took, "partial": worked.partial, "commits": worked.commits })
273 );
274 }
275 if let Err(error) = stats::keep(db, &repo.id, &worked).await {
276 worker::console_error!("the About of {} was worked out but not kept: {error}", repo.id);
277 stats::release(db, &repo.id).await;
278 }
279 }
280 Ok(None) => stats::release(db, &repo.id).await,
281 Err(error) => {
282 worker::console_error!("the About of {} was not worked out: {error}", repo.id);
283 stats::release(db, &repo.id).await;
284 }
285 }
286 }
287
288 /// `stars`.
289 pub(crate) async fn stars(&self, a: RepoViewArgs) -> Result<Outcome<Stars>> {
290 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
291 return Ok(not_found());
292 };
293 Ok(Outcome::Ok(self.star_count(&repo.id, &a.viewer).await?))
294 }
295
296 /// `star`: anyone signed in who can read the repository stars it.
297 pub(crate) async fn star(&self, a: StarArgs) -> Result<Outcome<Stars>> {
298 let viewer = Some(a.actor.clone());
299 let Some(repo) = self.readable(&a.path, &viewer).await? else {
300 return Ok(not_found());
301 };
302 if repo.fork_of.is_some() {
303 return Ok(not_found());
304 }
305 if a.actor.kind != g1t_contracts::PrincipalKind::User {
306 return Ok(Outcome::fail(FailureCode::Forbidden, "Only people star repositories."));
307 }
308 if a.starred {
309 self.db()
310 .prepare("INSERT OR IGNORE INTO repo_stars (repo_id, user_id, created_at) VALUES (?, ?, ?)")
311 .bind(&[repo.id.as_str().into(), a.actor.id.as_str().into(), rfc3339(now_ms()).into()])?
312 .run()
313 .await?;
314 } else {
315 self.db()
316 .prepare("DELETE FROM repo_stars WHERE repo_id = ? AND user_id = ?")
317 .bind(&[repo.id.as_str().into(), a.actor.id.as_str().into()])?
318 .run()
319 .await?;
320 }
321 Ok(Outcome::Ok(self.star_count(&repo.id, &viewer).await?))
322 }
323
324 /// The accounts behind user ids, by id.
325 async fn accounts(&self, ids: Vec<String>) -> HashMap<String, EmailOwner> {
326 let Some(identity) = &self.identity else { return HashMap::new() };
327 if ids.is_empty() {
328 return HashMap::new();
329 }
330 g1t_kit::call(identity, "accounts", &UsernamesArgs { ids }).await.unwrap_or_else(|error| {
331 worker::console_error!("stargazers' accounts not read: {error}");
332 HashMap::new()
333 })
334 }
335
336 /// `stargazers`.
337 pub(crate) async fn stargazers(&self, a: StargazersArgs) -> Result<Outcome<Vec<Stargazer>>> {
338 #[derive(Deserialize)]
339 struct Row {
340 user_id: String,
341 created_at: String,
342 }
343 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
344 return Ok(not_found());
345 };
346 let page = a.page.unwrap_or(1).max(1);
347 let rows = self
348 .db()
349 .prepare("SELECT user_id, created_at FROM repo_stars WHERE repo_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?")
350 .bind(&[
351 repo.id.as_str().into(),
352 JsValue::from_f64(f64::from(STARGAZERS_PAGE)),
353 JsValue::from_f64(f64::from((page - 1) * STARGAZERS_PAGE)),
354 ])?
355 .all()
356 .await?
357 .results::<Row>()?;
358 let accounts = self.accounts(rows.iter().map(|row| row.user_id.clone()).collect()).await;
359 Ok(Outcome::Ok(
360 rows.into_iter()
361 .filter_map(|row| {
362 let account = accounts.get(&row.user_id)?;
363 Some(Stargazer { username: account.username.clone(), avatar: account.avatar.clone(), starred_at: row.created_at })
364 })
365 .collect(),
366 ))
367 }
368
369 /// `starred`: by username, what the viewer may see of it.
370 pub(crate) async fn starred(&self, a: StarredArgs) -> Result<Vec<StarredRepo>> {
371 #[derive(Deserialize)]
372 struct Row {
373 repo_id: String,
374 created_at: String,
375 stars: f64,
376 }
377 let Some(identity) = &self.identity else { return Ok(Vec::new()) };
378 let person: Viewer = g1t_kit::call(identity, "user_by_username", &UsernameArgs { username: a.username.clone() }).await?;
379 let Some(person) = person else { return Ok(Vec::new()) };
380 let rows = self
381 .db()
382 .prepare(
383 "SELECT s.repo_id, s.created_at, (SELECT COUNT(*) FROM repo_stars t WHERE t.repo_id = s.repo_id) AS stars
384 FROM repo_stars s WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT 100",
385 )
386 .bind(&[person.id.as_str().into()])?
387 .all()
388 .await?
389 .results::<Row>()?;
390 let ids: Vec<String> = rows.iter().map(|row| row.repo_id.clone()).collect();
391 let readable: HashMap<String, Repo> = self.registry.readable(&ids, &a.viewer).await?.into_iter().map(|repo| (repo.id.clone(), repo)).collect();
392 Ok(rows
393 .into_iter()
394 .filter_map(|row| {
395 let repo = readable.get(&row.repo_id)?.clone();
396 Some(StarredRepo { repo, starred_at: row.created_at, stars: row.stars as u64 })
397 })
398 .collect())
399 }
400
401 /// `releases`.
402 pub(crate) async fn releases(&self, a: RepoViewArgs) -> Result<Outcome<Vec<Release>>> {
403 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
404 return Ok(not_found());
405 };
406 Ok(Outcome::Ok(self.visible_releases(&repo, &a.viewer, MAX_RELEASES).await?))
407 }
408
409 /// One release row of a repository, by id or tag.
410 async fn release_row(&self, repo_id: &str, id: Option<&str>, tag: Option<&str>) -> Result<Option<Release>> {
411 let (column, value) = match (id, tag) {
412 (Some(id), _) => ("id", id),
413 (None, Some(tag)) => ("tag_name", tag),
414 (None, None) => return Ok(None),
415 };
416 Ok(self
417 .db()
418 .prepare(format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = ? AND {column} = ?"))
419 .bind(&[repo_id.into(), value.into()])?
420 .first::<ReleaseRow>(None)
421 .await?
422 .map(Release::from))
423 }
424
425 /// `release`.
426 pub(crate) async fn release(&self, a: ReleaseArgs) -> Result<Outcome<Release>> {
427 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
428 return Ok(not_found());
429 };
430 let latest = latest_id(self.db(), &repo.id).await?;
431 let id = if a.latest { latest.clone() } else { a.id.clone() };
432 let found = self.release_row(&repo.id, id.as_deref(), if a.latest { None } else { a.tag.as_deref() }).await?;
433 Ok(match found {
434 Some(release) if !release.draft || can_write(&repo, &a.viewer) => Outcome::Ok(marked(release, latest.as_deref())),
435 _ => Outcome::fail(FailureCode::NotFound, "No such release."),
436 })
437 }
438
439 /// The repository, if the actor may make and change its releases.
440 async fn writable_for_releases(&self, path: &g1t_contracts::repos::RepoPath, actor: &User) -> Result<std::result::Result<Repo, Outcome<()>>> {
441 let viewer = Some(actor.clone());
442 let Some(repo) = self.readable(path, &viewer).await? else {
443 return Ok(Err(not_found()));
444 };
445 if !can_write(&repo, &viewer) {
446 return Ok(Err(Outcome::fail(
447 FailureCode::Forbidden,
448 g1t_contracts::access::needs(g1t_contracts::access::Capability::Push, &format!("{}/{}", repo.namespace, repo.name)),
449 )));
450 }
451 if !actor.verified {
452 return Ok(Err(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)));
453 }
454 if let Some((code, message)) = crate::lifecycle::read_only_refusal(&repo) {
455 return Ok(Err(Outcome::fail(code, message)));
456 }
457 Ok(Ok(repo))
458 }
459
460 /// `create_release`.
461 pub(crate) async fn create_release(&self, a: CreateReleaseArgs) -> Result<Outcome<Release>> {
462 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
463 Ok(repo) => repo,
464 Err(refused) => return Ok(refused.retype()),
465 };
466 let tag = a.tag_name.trim().trim_start_matches("refs/tags/").to_owned();
467 if !is_valid_branch_name(&tag) {
468 return Ok(Outcome::fail(FailureCode::Invalid, format!("{tag:?} cannot be a tag's name.")));
469 }
470 if let Err(message) = check_release_text(a.name.as_deref(), a.body.as_deref()) {
471 return Ok(Outcome::fail(FailureCode::Invalid, message));
472 }
473 if self.release_row(&repo.id, None, Some(&tag)).await?.is_some() {
474 return Ok(Outcome::fail(FailureCode::Conflict, format!("{tag} already has a release: change that one instead.")));
475 }
476 let git = self.store.open(&store_key(&repo)).await?;
477 let access = git.access(Scope::Read).await?;
478 let refs = crate::refs::heads_and_tags(crate::refs::all(&access).await?);
479 let existing = refs.iter().find(|(name, _)| *name == format!("refs/tags/{tag}")).map(|(_, hash)| hash.clone());
480 let target = match existing {
481 Some(hash) => match git.log(&hash, 1).await?.into_iter().next() {
482 Some(commit) => commit.hash,
483 None => hash,
484 },
485 None => {
486 // A new tag, made at the target.
487 let from = a.target.as_deref().map(str::trim).filter(|target| !target.is_empty()).unwrap_or(&repo.default_branch);
488 let Some(commit) = git.log(from, 1).await?.into_iter().next() else {
489 return Ok(Outcome::fail(FailureCode::NotFound, format!("{from} is not a branch or commit of this repository.")));
490 };
491 let git_ref = format!("refs/tags/{tag}");
492 let change = g1t_rules::push::RefChange {
493 git_ref: git_ref.clone(),
494 old: None,
495 new: Some(commit.hash.clone()),
496 fast_forward: None,
497 commits: Vec::new(),
498 complete: true,
499 };
500 if let crate::rules::Ruled::Refused { message } =
501 self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::CreateRef, vec![change]).await?
502 {
503 return Ok(Outcome::fail(FailureCode::Forbidden, message));
504 }
505 let write = git.access(Scope::Write).await?;
506 // The commit is there already: the pack is empty.
507 if let Err(reason) = land::push_ref(&write, &git_ref, None, &commit.hash, Some(write_pack(&[]))).await? {
508 return Ok(Outcome::fail(FailureCode::Conflict, format!("The tag {tag} could not be made: {reason}")));
509 }
510 self.refs_moved(&repo.id).await;
511 self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor), None).await?;
512 commit.hash
513 }
514 };
515 let now = rfc3339(now_ms());
516 let id = new_id("rel", now_ms());
517 let published: JsValue = if a.draft { JsValue::NULL } else { now.as_str().into() };
518 self.db()
519 .prepare(
520 "INSERT INTO releases (id, repo_id, tag_name, target, name, body, draft, prerelease, author_id, author, created_at, published_at, updated_at)
521 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?11)",
522 )
523 .bind(&[
524 id.as_str().into(),
525 repo.id.as_str().into(),
526 tag.as_str().into(),
527 target.as_str().into(),
528 title(a.name.as_deref()).map_or(JsValue::NULL, |name| name.into()),
529 a.body.unwrap_or_default().into(),
530 JsValue::from_f64(if a.draft { 1.0 } else { 0.0 }),
531 JsValue::from_f64(if a.prerelease { 1.0 } else { 0.0 }),
532 a.actor.id.as_str().into(),
533 a.actor.username.as_str().into(),
534 now.as_str().into(),
535 published,
536 ])?
537 .run()
538 .await?;
539 let made = self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor.clone()), id: Some(id), tag: None, latest: false }).await?;
540 if let Outcome::Ok(release) = &made {
541 self.publish_release(&repo, &a.actor, None, Some(release)).await;
542 }
543 Ok(made)
544 }
545
546 /// `update_release`.
547 pub(crate) async fn update_release(&self, a: UpdateReleaseArgs) -> Result<Outcome<Release>> {
548 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
549 Ok(repo) => repo,
550 Err(refused) => return Ok(refused.retype()),
551 };
552 let Some(release) = self.release_row(&repo.id, Some(&a.id), None).await? else {
553 return Ok(Outcome::fail(FailureCode::NotFound, "No such release."));
554 };
555 if let Err(message) = check_release_text(a.name.as_deref(), a.body.as_deref()) {
556 return Ok(Outcome::fail(FailureCode::Invalid, message));
557 }
558 let now = rfc3339(now_ms());
559 let name = match &a.name {
560 Some(name) => title(Some(name)),
561 None => release.name.clone(),
562 };
563 let draft = a.draft.unwrap_or(release.draft);
564 // Published the first time it stops being a draft; a release made a
565 // draft again keeps no date.
566 let published_at = if draft { None } else { release.published_at.clone().or(Some(now.clone())) };
567 self.db()
568 .prepare("UPDATE releases SET name = ?2, body = ?3, draft = ?4, prerelease = ?5, published_at = ?6, updated_at = ?7 WHERE id = ?1")
569 .bind(&[
570 release.id.as_str().into(),
571 name.map_or(JsValue::NULL, |name| name.into()),
572 a.body.unwrap_or_else(|| release.body.clone()).into(),
573 JsValue::from_f64(if draft { 1.0 } else { 0.0 }),
574 JsValue::from_f64(if a.prerelease.unwrap_or(release.prerelease) { 1.0 } else { 0.0 }),
575 published_at.map_or(JsValue::NULL, |at| at.into()),
576 now.into(),
577 ])?
578 .run()
579 .await?;
580 let changed = self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor.clone()), id: Some(release.id.clone()), tag: None, latest: false }).await?;
581 if let Outcome::Ok(after) = &changed {
582 self.publish_release(&repo, &a.actor, Some(&release), Some(after)).await;
583 }
584 Ok(changed)
585 }
586
587 /// `delete_release`: the tag stays.
588 pub(crate) async fn delete_release(&self, a: DeleteReleaseArgs) -> Result<Outcome<bool>> {
589 let repo = match self.writable_for_releases(&a.path, &a.actor).await? {
590 Ok(repo) => repo,
591 Err(refused) => return Ok(refused.retype()),
592 };
593 let before = self.release_row(&repo.id, Some(&a.id), None).await?;
594 let deleted = self
595 .db()
596 .prepare("DELETE FROM releases WHERE repo_id = ? AND id = ? RETURNING id")
597 .bind(&[repo.id.as_str().into(), a.id.as_str().into()])?
598 .first::<serde_json::Value>(None)
599 .await?;
600 Ok(match deleted {
601 Some(_) => {
602 if let Some(before) = &before {
603 self.publish_release(&repo, &a.actor, Some(before), None).await;
604 }
605 Outcome::Ok(true)
606 }
607 None => Outcome::fail(FailureCode::NotFound, "No such release."),
608 })
609 }
610
611 /// Publishes a release's change as the release activities it amounts
612 /// to (`release_actions`), one `release.*` event each, for workflows
613 /// and webhooks. What a workflow job's token did is marked as its, so it
614 /// starts no workflows. A failure is logged: the change itself happened.
615 async fn publish_release(&self, repo: &Repo, actor: &User, before: Option<&Release>, after: Option<&Release>) {
616 let state = |release: &Release| ReleaseState { draft: release.draft, prerelease: release.prerelease };
617 let Some(release) = after.or(before) else { return };
618 let changes = match (before, after) {
619 (Some(before), Some(after)) => {
620 let mut changes = serde_json::Map::new();
621 if before.name != after.name {
622 changes.insert("name".into(), serde_json::json!({ "from": before.name }));
623 }
624 if before.body != after.body {
625 changes.insert("body".into(), serde_json::json!({ "from": before.body }));
626 }
627 (!changes.is_empty()).then_some(serde_json::Value::Object(changes))
628 }
629 _ => None,
630 };
631 for action in release_actions(before.map(state), after.map(state)) {
632 let Some(kind) = release_kind(action) else { continue };
633 let data = ReleaseEvent {
634 release_id: release.id.clone(),
635 repo_id: repo.id.clone(),
636 tag_name: release.tag_name.clone(),
637 release: release.clone(),
638 changes: if action == "edited" { changes.clone() } else { None },
639 };
640 let published = self
641 .publish(NewEvent {
642 kind,
643 source: crate::SOURCE,
644 repo_id: Some(repo.id.clone()),
645 actor: Some(actor.id.clone()),
646 data: g1t_contracts::events::marked(data, Some(actor)),
647 })
648 .await;
649 if let Err(error) = published {
650 worker::console_error!("{kind} for {} not published: {error}", release.id);
651 }
652 }
653 }
654}
655
656/// A refusal of one type, as another: it carries no value.
657trait Retype {
658 fn retype<T>(self) -> Outcome<T>;
659}
660
661impl Retype for Outcome<()> {
662 fn retype<T>(self) -> Outcome<T> {
663 match self {
664 Outcome::Fail(failure) => Outcome::Fail(failure),
665 Outcome::Ok(()) => Outcome::fail(FailureCode::Invalid, "Nothing to do."),
666 }
667 }
668}
669
670/// Runs this long or longer are logged (`refresh`).
671const SLOW_RUN_MS: u64 = 5_000;
672
673/// Works out `repo`'s About after the answer has gone, when an answer
674/// found it behind. What the run read is kept for the next one (trees,
675/// blob sizes and history by hash, store.rs) by writes it starts as it
676/// goes; the `waitUntil` lasts until they are done, or the next run would
677/// read the same again.
678pub(crate) fn refresh_later(env: &Env, ctx: &worker::Context, refresh: Refresh) {
679 let Some(repo) = refresh else { return };
680 let env = env.clone();
681 ctx.wait_until(async move {
682 match crate::service(&env) {
683 Ok(repos) => {
684 repos.refresh(&repo, stats::BUDGET_MS).await;
685 repos.deferred.settle().await;
686 }
687 Err(error) => worker::console_error!("the About of {} was not worked out: {error}", repo.id),
688 }
689 });
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn release_titles_are_tidied_and_text_is_bounded() {
698 assert_eq!(title(Some(" v1.0 ")).as_deref(), Some("v1.0"));
699 assert_eq!(title(Some(" ")), None);
700 assert_eq!(title(None), None);
701 assert!(check_release_text(Some("ok"), Some("notes")).is_ok());
702 assert!(check_release_text(Some(&"x".repeat(MAX_RELEASE_NAME_CHARS + 1)), None).is_err());
703 assert!(check_release_text(None, Some(&"x".repeat(MAX_RELEASE_BODY_CHARS + 1))).is_err());
704 }
705
706 #[test]
707 fn only_the_latest_is_marked() {
708 let release = Release {
709 id: "rel_1".into(),
710 tag_name: "v1".into(),
711 target: "c".into(),
712 name: None,
713 body: String::new(),
714 draft: false,
715 prerelease: false,
716 author: None,
717 created_at: String::new(),
718 published_at: None,
719 latest: false,
720 };
721 assert!(marked(release.clone(), Some("rel_1")).latest);
722 assert!(!marked(release, Some("rel_2")).latest);
723 }
724}