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.
| Project settings say who created the project, by name | 1 | //! Reading workflow files: at any commit for a run, and from the default |
| 2 | //! branch into the `workflows` table, which lists them, holds their | |
| 3 | //! schedules and remembers which are turned off. | |
| 4 | ||
| 5 | use g1t_actions::workflow::{self, FOLDER}; | |
| 6 | use g1t_contracts::new_id; | |
| 7 | use g1t_contracts::repos::{BlobArgs, BlobView, EntryKind, Repo, RepoPath, TreeArgs, TreeView}; | |
| 8 | use g1t_contracts::time::rfc3339; | |
| 9 | use g1t_contracts::{Outcome, User}; | |
| 10 | use g1t_kit::now_ms; | |
| 11 | use serde::Deserialize; | |
| 12 | use worker::Result; | |
| 13 | ||
| 14 | use crate::{Actions, Count, MAX_WORKFLOWS, optional}; | |
| 15 | ||
| 16 | /// One workflow file as read at a commit. | |
| 17 | pub struct WorkflowFile { | |
| 18 | pub path: String, | |
| 19 | pub source: String, | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 20 | /// A workspace's required workflow (managed.rs): no one in the |
| 21 | /// repository can turn it off. | |
| 22 | pub required: bool, | |
| Project settings say who created the project, by name | 23 | } |
| 24 | ||
| 25 | /// The files at a commit, and the commit the ref resolved to. | |
| 26 | pub struct Read { | |
| 27 | pub files: Vec<WorkflowFile>, | |
| 28 | pub head: Option<String>, | |
| 29 | } | |
| 30 | ||
| 31 | #[derive(Deserialize)] | |
| 32 | pub struct WorkflowRow { | |
| 33 | pub id: String, | |
| 34 | pub repo_id: String, | |
| 35 | pub repo: String, | |
| 36 | pub path: String, | |
| 37 | pub name: String, | |
| 38 | pub source: String, | |
| 39 | pub events: String, | |
| 40 | pub crons: String, | |
| 41 | pub error: Option<String>, | |
| 42 | pub state: String, | |
| 43 | pub updated_at: String, | |
| 44 | } | |
| 45 | ||
| 46 | /// What a workflow's row says once its file has left the default branch. | |
| 47 | pub const GONE: &str = "Its file is no longer on the default branch."; | |
| 48 | ||
| 49 | /// Whether a synced workflow could start on `event`: it lists the event, | |
| 50 | /// or it did not parse (and is still on the branch), so reading it again | |
| 51 | /// reports why. | |
| 52 | pub fn could_start(events: &str, error: Option<&str>, event: &str) -> bool { | |
| 53 | match error { | |
| 54 | Some(error) => error != GONE, | |
| 55 | None => serde_json::from_str::<Vec<String>>(events).is_ok_and(|events| events.iter().any(|e| e == event)), | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | impl Actions { | |
| 60 | /// The workflow files of `path` as of `git_ref` (the default branch | |
| 61 | /// when absent). | |
| 62 | pub async fn read_workflows(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>) -> Result<Read> { | |
| Merge branch 'mirroring' into artifacts-mode | 63 | self.read_folders(path, actor, git_ref, &[FOLDER]).await |
| 64 | } | |
| 65 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 66 | /// The workflow files a repository runs: `.g1t/workflows`, with the |
| 67 | /// managed workflows installed in it and its workspace's that target | |
| 68 | /// it, as managed.rs puts them together. | |
| 69 | pub async fn read_for(&self, repo: &Repo, actor: &User, git_ref: Option<&str>) -> Result<Read> { | |
| Merge branch 'mirroring' into artifacts-mode | 70 | let path = RepoPath { |
| 71 | namespace: repo.namespace.clone(), | |
| 72 | name: repo.name.clone(), | |
| Project settings say who created the project, by name | 73 | }; |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 74 | let mut read = self.read_workflows(&path, actor, git_ref).await?; |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 75 | // And the managed workflows beside them (managed.rs). |
| 76 | read.files = self.with_managed(repo, read.files).await?; | |
| A repository can run a managed workflow: one installed by g1t's own services without a file in the repository. It is listed, started, run and re-run like any workflow in `.g1t/workflows/`, under the path `managed/<name>.yml`, and a workflow of the same `name:` in the repository runs in its place, so moving one into the repository is committing its source. The Actions page says a managed workflow is managed by g1t rather than linking to a file. Nothing installs one yet; the deploy workflow deployments will install, which calls g1t's reusable deploy workflow with a project's settings as its inputs and reports as `g1t / deploy`, is written and tested but not installed. | 77 | Ok(read) |
| Merge branch 'mirroring' into artifacts-mode | 78 | } |
| 79 | ||
| 80 | async fn read_folders(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>, folders: &[&str]) -> Result<Read> { | |
| 81 | let viewer = Some(actor.clone()); | |
| Project settings say who created the project, by name | 82 | let mut files = Vec::new(); |
| Merge branch 'mirroring' into artifacts-mode | 83 | let mut found_head = None; |
| 84 | for folder in folders { | |
| 85 | let tree: Outcome<TreeView> = g1t_kit::call( | |
| Project settings say who created the project, by name | 86 | &self.repos, |
| Merge branch 'mirroring' into artifacts-mode | 87 | "tree", |
| 88 | &TreeArgs { | |
| Project settings say who created the project, by name | 89 | path: path.clone(), |
| 90 | viewer: viewer.clone(), | |
| Merge branch 'mirroring' into artifacts-mode | 91 | git_ref: git_ref.map(str::to_owned), |
| 92 | tree_path: (*folder).to_owned(), | |
| Project settings say who created the project, by name | 93 | }, |
| 94 | ) | |
| 95 | .await?; | |
| Merge branch 'mirroring' into artifacts-mode | 96 | let (entries, head, resolved) = match tree { |
| 97 | Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref), | |
| 98 | // No folder: no workflows from it. | |
| 99 | Outcome::Fail(_) => continue, | |
| 100 | }; | |
| 101 | let at = head.clone().unwrap_or(resolved); | |
| 102 | found_head = found_head.or(head); | |
| 103 | for entry in entries | |
| 104 | .into_iter() | |
| 105 | .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec)) | |
| 106 | .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml")) | |
| 107 | .take(MAX_WORKFLOWS.saturating_sub(files.len())) | |
| 108 | { | |
| 109 | let file_path = format!("{folder}/{}", entry.name); | |
| 110 | let blob: Outcome<BlobView> = g1t_kit::call( | |
| 111 | &self.repos, | |
| 112 | "blob", | |
| 113 | &BlobArgs { | |
| 114 | path: path.clone(), | |
| 115 | viewer: viewer.clone(), | |
| 116 | git_ref: at.clone(), | |
| 117 | file_path: file_path.clone(), | |
| 118 | }, | |
| 119 | ) | |
| 120 | .await?; | |
| 121 | if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob { | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 122 | files.push(WorkflowFile { path: file_path, source, required: false }); |
| Merge branch 'mirroring' into artifacts-mode | 123 | } |
| Project settings say who created the project, by name | 124 | } |
| 125 | } | |
| Merge branch 'mirroring' into artifacts-mode | 126 | Ok(Read { files, head: found_head }) |
| Project settings say who created the project, by name | 127 | } |
| 128 | ||
| 129 | /// Keeps the `workflows` table in step with the default branch. | |
| 130 | pub async fn sync(&self, repo: &Repo, actor: &User) -> Result<()> { | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 131 | let read = self.read_for(repo, actor, None).await?; |
| Project settings say who created the project, by name | 132 | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 133 | let now = rfc3339(now_ms()); | |
| 134 | let mut statements = Vec::new(); | |
| 135 | for file in &read.files { | |
| 136 | let parsed = workflow::parse(&file.source); | |
| 137 | let (name, error, events, crons) = match &parsed { | |
| 138 | Ok(parsed) => ( | |
| 139 | parsed.display_name(&file.path), | |
| 140 | None, | |
| 141 | parsed.triggers.iter().map(|t| t.event.clone()).collect::<Vec<_>>(), | |
| 142 | parsed.trigger("schedule").map(|t| t.crons.clone()).unwrap_or_default(), | |
| 143 | ), | |
| 144 | Err(problem) => (file.path.clone(), Some(problem.clone()), Vec::new(), Vec::new()), | |
| 145 | }; | |
| 146 | statements.push( | |
| 147 | self.db | |
| 148 | .prepare( | |
| 149 | "INSERT INTO workflows (id, repo_id, repo, path, name, source, events, crons, error, updated_at) | |
| 150 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 151 | ON CONFLICT (repo_id, path) DO UPDATE SET | |
| 152 | repo = excluded.repo, name = excluded.name, source = excluded.source, | |
| 153 | events = excluded.events, crons = excluded.crons, error = excluded.error, | |
| 154 | updated_at = excluded.updated_at", | |
| 155 | ) | |
| 156 | .bind(&[ | |
| 157 | new_id("wfl", now_ms()).into(), | |
| 158 | repo.id.as_str().into(), | |
| 159 | full_name.as_str().into(), | |
| 160 | file.path.as_str().into(), | |
| 161 | name.into(), | |
| 162 | file.source.as_str().into(), | |
| 163 | serde_json::to_string(&events)?.into(), | |
| 164 | serde_json::to_string(&crons)?.into(), | |
| 165 | optional(error.as_deref()), | |
| 166 | now.as_str().into(), | |
| 167 | ])?, | |
| 168 | ); | |
| 169 | } | |
| 170 | // A workflow whose file is gone keeps its runs, but no longer runs | |
| 171 | // on schedule or by hand: it is listed only while it has runs. | |
| 172 | let kept: Vec<String> = read.files.iter().map(|file| file.path.clone()).collect(); | |
| 173 | let existing = self | |
| 174 | .db | |
| 175 | .prepare("SELECT * FROM workflows WHERE repo_id = ?") | |
| 176 | .bind(&[repo.id.as_str().into()])? | |
| 177 | .all() | |
| 178 | .await? | |
| 179 | .results::<WorkflowRow>()?; | |
| 180 | for row in existing.iter().filter(|row| !kept.contains(&row.path)) { | |
| 181 | statements.push( | |
| 182 | self.db | |
| 183 | .prepare("UPDATE workflows SET crons = '[]', error = ? WHERE id = ?") | |
| 184 | .bind(&[GONE.into(), row.id.as_str().into()])?, | |
| 185 | ); | |
| 186 | } | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 187 | // A workspace's required workflow cannot stay turned off, even one |
| 188 | // turned off while it was a default. | |
| 189 | let required: Vec<&str> = read.files.iter().filter(|file| file.required).map(|file| file.path.as_str()).collect(); | |
| 190 | if !required.is_empty() { | |
| 191 | statements.push( | |
| 192 | self.db | |
| 193 | .prepare("UPDATE workflows SET state = 'active' WHERE repo_id = ? AND path IN (SELECT value FROM json_each(?))") | |
| 194 | .bind(&[repo.id.as_str().into(), serde_json::to_string(&required)?.into()])?, | |
| 195 | ); | |
| 196 | } | |
| Project settings say who created the project, by name | 197 | statements.push( |
| 198 | self.db | |
| 199 | .prepare("INSERT OR REPLACE INTO synced (repo_id, at) VALUES (?, ?)") | |
| 200 | .bind(&[repo.id.as_str().into(), now.into()])?, | |
| 201 | ); | |
| 202 | self.db.batch(statements).await?; | |
| 203 | Ok(()) | |
| 204 | } | |
| 205 | ||
| 206 | /// Whether any of the repository's default-branch workflows could | |
| 207 | /// start on `event`, from the synced table; None before the first sync. | |
| 208 | pub async fn listens(&self, repo_id: &str, event: &str) -> Result<Option<bool>> { | |
| 209 | #[derive(Deserialize)] | |
| 210 | struct Row { | |
| 211 | events: String, | |
| 212 | error: Option<String>, | |
| 213 | } | |
| 214 | if !self.synced(repo_id).await? { | |
| 215 | return Ok(None); | |
| 216 | } | |
| 217 | let rows = self | |
| 218 | .db | |
| 219 | .prepare("SELECT events, error FROM workflows WHERE repo_id = ? AND state = 'active'") | |
| 220 | .bind(&[repo_id.into()])? | |
| 221 | .all() | |
| 222 | .await? | |
| 223 | .results::<Row>()?; | |
| 224 | Ok(Some(rows.iter().any(|row| could_start(&row.events, row.error.as_deref(), event)))) | |
| 225 | } | |
| 226 | ||
| 227 | pub async fn synced(&self, repo_id: &str) -> Result<bool> { | |
| 228 | Ok(self | |
| 229 | .db | |
| 230 | .prepare("SELECT COUNT(*) AS n FROM synced WHERE repo_id = ?") | |
| 231 | .bind(&[repo_id.into()])? | |
| 232 | .first::<Count>(None) | |
| 233 | .await? | |
| 234 | .is_some_and(|count| count.n > 0)) | |
| 235 | } | |
| 236 | ||
| 237 | /// The row for a workflow file, made if it is new (a file that exists | |
| 238 | /// only on a branch still gets its runs counted and listed). | |
| 239 | pub async fn workflow_row(&self, repo: &Repo, path: &str, name: &str, source: &str) -> Result<WorkflowRow> { | |
| 240 | let now = rfc3339(now_ms()); | |
| 241 | self.db | |
| 242 | .prepare( | |
| 243 | "INSERT INTO workflows (id, repo_id, repo, path, name, source, events, error, updated_at) | |
| 244 | VALUES (?, ?, ?, ?, ?, ?, '[]', 'Its file is not on the default branch.', ?) | |
| 245 | ON CONFLICT (repo_id, path) DO NOTHING", | |
| 246 | ) | |
| 247 | .bind(&[ | |
| 248 | new_id("wfl", now_ms()).into(), | |
| 249 | repo.id.as_str().into(), | |
| 250 | format!("{}/{}", repo.namespace, repo.name).into(), | |
| 251 | path.into(), | |
| 252 | name.into(), | |
| 253 | source.into(), | |
| 254 | now.into(), | |
| 255 | ])? | |
| 256 | .run() | |
| 257 | .await?; | |
| 258 | self.db | |
| 259 | .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?") | |
| 260 | .bind(&[repo.id.as_str().into(), path.into()])? | |
| 261 | .first::<WorkflowRow>(None) | |
| 262 | .await? | |
| 263 | .ok_or_else(|| worker::Error::RustError("the workflow was not recorded".into())) | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | #[cfg(test)] | |
| 268 | mod tests { | |
| 269 | use super::*; | |
| 270 | ||
| 271 | #[test] | |
| 272 | fn a_synced_workflow_starts_on_the_events_it_lists_or_when_broken() { | |
| 273 | assert!(could_start(r#"["push","issues"]"#, None, "issues")); | |
| 274 | assert!(!could_start(r#"["push","pull_request"]"#, None, "issue_comment")); | |
| 275 | // A file that does not parse is read again, so its error shows. | |
| 276 | assert!(could_start("[]", Some("bad yaml"), "issues")); | |
| 277 | // A file gone from the branch starts nothing. | |
| 278 | assert!(!could_start("[]", Some(GONE), "issues")); | |
| 279 | } | |
| 280 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.