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.
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | ||
| 12 | use std::collections::HashMap; | |
| 13 | ||
| 14 | use g1t_contracts::about::*; | |
| 15 | use g1t_contracts::accounts::EmailOwner; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 16 | use g1t_contracts::events::{NewEvent, ReleaseEvent, ReleaseState, release_actions, release_kind}; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 17 | use g1t_contracts::identity::{UsernameArgs, UsernamesArgs}; |
| 18 | use g1t_contracts::repos::{Repo, is_valid_branch_name}; | |
| 19 | use g1t_contracts::time::rfc3339; | |
| 20 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| 21 | use g1t_kit::now_ms; | |
| 22 | use g1t_scan::pack::write_pack; | |
| 23 | use serde::Deserialize; | |
| 24 | use worker::wasm_bindgen::JsValue; | |
| 25 | use worker::{D1Database, Env, Result}; | |
| 26 | ||
| 27 | use crate::registry::{can_write, store_key}; | |
| 28 | use crate::store::{GitRepo, GitStore, Scope}; | |
| 29 | use 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. | |
| 33 | pub(crate) type Refresh = Option<Repo>; | |
| 34 | ||
| 35 | /// Releases a list holds at most. | |
| 36 | const MAX_RELEASES: u32 = 100; | |
| 37 | /// Stargazers a page holds. | |
| 38 | const STARGAZERS_PAGE: u32 = 100; | |
| 39 | ||
| 40 | #[derive(Debug, Deserialize)] | |
| 41 | struct 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 | ||
| 54 | impl 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 | ||
| 72 | const 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. | |
| 76 | async 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 | ||
| 92 | fn 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. | |
| 98 | fn title(name: Option<&str>) -> Option<String> { | |
| 99 | name.map(str::trim).filter(|name| !name.is_empty()).map(str::to_owned) | |
| 100 | } | |
| 101 | ||
| 102 | fn 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 | ||
| 112 | impl<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 | } | |
| Merge branch 'mirroring' into artifacts-mode | 444 | if let Some((code, message)) = crate::lifecycle::read_only_refusal(&repo) { |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 501 | self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor), None).await?; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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?; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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) | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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()), | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 562 | a.body.unwrap_or_else(|| release.body.clone()).into(), |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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?; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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) | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | }; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 583 | let before = self.release_row(&repo.id, Some(&a.id), None).await?; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 { | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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 | } | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 597 | None => Outcome::fail(FailureCode::NotFound, "No such release."), |
| 598 | }) | |
| 599 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 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 | } | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 644 | } |
| 645 | ||
| 646 | /// A refusal of one type, as another: it carries no value. | |
| 647 | trait Retype { | |
| 648 | fn retype<T>(self) -> Outcome<T>; | |
| 649 | } | |
| 650 | ||
| 651 | impl 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. | |
| 662 | pub(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)] | |
| 674 | mod 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.