Skip to content
705 linesCodeBlameRaw

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-a69aeabc4b0deeb971//! 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;
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts16use g1t_contracts::events::{NewEvent, ReleaseEvent, ReleaseState, release_actions, release_kind};
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9717use 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 }
Merge branch 'mirroring' into artifacts-mode444 if let Some((code, message)) = crate::lifecycle::read_only_refusal(&repo) {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97445 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-a69aeabc4b0deeb97502 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 timeouts529 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-a69aeabc4b0deeb97534 }
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 timeouts562 a.body.unwrap_or_else(|| release.body.clone()).into(),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97563 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 timeouts570 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-a69aeabc4b0deeb97575 }
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 timeouts583 let before = self.release_row(&repo.id, Some(&a.id), None).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97584 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 timeouts591 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-a69aeabc4b0deeb97597 None => Outcome::fail(FailureCode::NotFound, "No such release."),
598 })
599 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts600
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-a69aeabc4b0deeb97644}
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}

This file's history is long; its oldest lines are credited to the oldest commit read.