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 'worktree-agent-a6a121745e81f639f' | 1 | //! What starts a run: an event on the bus, a schedule, or someone running a |
| 2 | //! workflow by hand. Each finds the workflows that want it, at the commit | |
| 3 | //! the event is about, and checks their filters. | |
| 4 | ||
| 5 | use g1t_actions::events::{RunInfo, github_events}; | |
| 6 | use g1t_actions::workflow::{self, Trigger, Workflow}; | |
| 7 | use g1t_contracts::access::{self, Capability}; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 8 | use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun}; |
| 9 | use g1t_contracts::events::{Event, caused_by_job}; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 10 | use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs}; |
| 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. | 11 | use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath}; |
| 12 | use g1t_contracts::work::{IssueDetail, Label, PullDetail, ViewArgs}; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 14 | use g1t_contracts::time::rfc3339; |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 15 | use g1t_kit::now_ms; |
| 16 | use serde_json::{Map, Value, json}; | |
| 17 | use worker::Result; | |
| 18 | ||
| 19 | use crate::plan::NewRun; | |
| 20 | use crate::sync::{Read, WorkflowRow}; | |
| 21 | use crate::{API, Actions, SITE, check, fail, payload}; | |
| 22 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 23 | /// How many commits a push's log reads: its `commits`, and how far back |
| 24 | /// `before` is looked for to tell a forced push. | |
| 25 | const PUSH_LOG: u32 = 20; | |
| 26 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 27 | /// What an event is about, worked out once for every workflow it starts. |
| 28 | struct Subject { | |
| 29 | /// Where the workflow files are read, and at which commit. | |
| 30 | source: RepoPath, | |
| 31 | source_ref: Option<String>, | |
| 32 | git_ref: String, | |
| 33 | sha: String, | |
| 34 | head_ref: Option<String>, | |
| 35 | base_ref: Option<String>, | |
| 36 | pull: Option<u32>, | |
| 37 | /// The branch or tag for `branches`/`tags` filters; for pull requests, | |
| 38 | /// the branch they merge into. | |
| 39 | filter_ref: String, | |
| 40 | /// The files it changes, for `paths` filters; `None` until needed. | |
| 41 | paths: Option<Vec<String>>, | |
| 42 | /// For a push, what to compare to find the files. | |
| 43 | compare: Option<(Option<String>, String)>, | |
| 44 | payload: Value, | |
| 45 | title: String, | |
| 46 | trusted: bool, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 47 | /// Why its runs wait for approval first: a pull request from outside, |
| 48 | /// by the repository's approval policy (protection.rs). | |
| 49 | approval: Option<String>, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 50 | /// What the run builds when it is not `sha`: a pull request's test |
| 51 | /// merge commit (`refs/pull/<n>/merge`), as on GitHub, which becomes | |
| 52 | /// `github.sha` while statuses and checks stay on `sha`, its head. | |
| 53 | merge: Option<String>, | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 54 | } |
| 55 | ||
| 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. | 56 | impl Subject { |
| 57 | /// What the event changes, once compared: the paths for `paths` | |
| 58 | /// filters, and a push's commit's `added`, `removed` and `modified`. | |
| 59 | fn changed(&mut self, changes: Vec<(String, FileStatus)>) { | |
| 60 | payload::push_changes(&mut self.payload, &changes); | |
| 61 | self.paths = Some(changes.into_iter().map(|(path, _)| path).collect()); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | /// The address a push's `pusher.email` gives: g1t's for its agent and | |
| 66 | /// itself, a person's noreply address (which needs no lookup) for them, | |
| 67 | /// and none for anyone else. | |
| 68 | fn pusher_email(actor: Option<&str>, username: &str) -> Option<String> { | |
| 69 | match actor? { | |
| 70 | id if id == AGENT_ID || id == g1t_contracts::system::ID => Some(g1t_contracts::system::EMAIL.to_owned()), | |
| 71 | id if id.starts_with("usr_") => Some(g1t_contracts::accounts::noreply_address(id, username)), | |
| 72 | _ => None, | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 76 | /// Whether a deployment's `ref` is a commit's full hash rather than a |
| 77 | /// branch or tag. | |
| 78 | fn is_commit(name: &str) -> bool { | |
| 79 | name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit()) | |
| 80 | } | |
| 81 | ||
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 82 | /// What a ref made or deleted is, as `create` and `delete` name it: |
| 83 | /// `branch` or `tag`, and its short name. `None` for any other ref, and | |
| 84 | /// for the merge queue's own branches, which come and go with every | |
| 85 | /// queued change. | |
| 86 | fn branch_or_tag(git_ref: &str) -> Option<(&'static str, &str)> { | |
| 87 | if git_ref.starts_with("refs/heads/g1t-queue/") { | |
| 88 | return None; | |
| 89 | } | |
| 90 | match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) { | |
| 91 | (Some(branch), _) => Some(("branch", branch)), | |
| 92 | (_, Some(tag)) => Some(("tag", tag)), | |
| 93 | _ => None, | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /// Events GitHub takes no `branches`, `tags` or `paths` filters on. | |
| 98 | fn unfiltered(event_name: &str) -> bool { | |
| 99 | matches!(event_name, "create" | "delete") | |
| 100 | } | |
| 101 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 102 | /// What a pull request's run builds, given its test merge commit. |
| 103 | #[derive(Debug, PartialEq, Eq)] | |
| 104 | enum PullBuild { | |
| 105 | /// The merge commit, as on GitHub. | |
| 106 | Merge(String), | |
| 107 | /// The head: g1t could not make the merge commit itself, or a | |
| 108 | /// conflicting pull request's event that still runs without one. | |
| 109 | Head, | |
| 110 | /// No run: GitHub starts no `pull_request` workflow while the pull | |
| 111 | /// request has conflicts, since there is no merge commit to build. The | |
| 112 | /// pull request's checks say so (apps/web `pull.tsx`). | |
| 113 | Nothing, | |
| 114 | } | |
| 115 | ||
| 116 | fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild { | |
| 117 | match made { | |
| 118 | PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()), | |
| 119 | PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing, | |
| 120 | PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head, | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 124 | /// Whether an `*.assigned` event added nobody: g1t says so for a change |
| 125 | /// that only took people off. | |
| 126 | fn nobody_added(data: &Value) -> bool { | |
| 127 | data["added"].as_array().is_some_and(Vec::is_empty) | |
| 128 | } | |
| 129 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 130 | /// Whether whoever a pull request is for is trusted without asking |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 131 | /// identity: someone whose role here is known to allow pushing. |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 132 | fn trusted_outright(owner: &User, repo: &Repo) -> bool { |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 133 | access::can(Some(owner), repo, Capability::Push) |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 134 | } |
| 135 | ||
| 136 | impl Actions { | |
| 137 | async fn username(&self, id: Option<&str>) -> Result<Option<String>> { | |
| 138 | let Some(id) = id else { return Ok(None) }; | |
| 139 | if id == AGENT_ID { | |
| 140 | return Ok(Some(AGENT_NAME.to_owned())); | |
| 141 | } | |
| 142 | let names: std::collections::HashMap<String, String> = | |
| 143 | g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?; | |
| 144 | Ok(names.get(id).cloned()) | |
| 145 | } | |
| 146 | ||
| 147 | /// Whether whoever a pull request is for (Pull::owner: whoever asked | |
| 148 | /// g1t for it, or its author) could push to the repository, so its | |
| 149 | /// runs get the secrets and a token. Anyone else's, a reader's included | |
| 150 | /// (who may open one on a private repository too), runs without them. | |
| 151 | /// A change g1t made for someone is trusted as they are. | |
| 152 | async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> { | |
| 153 | if trusted_outright(owner, repo) { | |
| 154 | return Ok(true); | |
| 155 | } | |
| 156 | // Stored authors carry no memberships or grants: ask identity, as | |
| 157 | // the workspace (which may see anyone's permission). | |
| 158 | let permission: Outcome<access::PermissionInfo> = g1t_kit::call( | |
| 159 | &self.identity, | |
| 160 | "collaborator_permission", | |
| 161 | &access::CollaboratorPermissionArgs { | |
| 162 | viewer: Some(ws.clone()), | |
| 163 | path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, | |
| 164 | username: owner.username.clone(), | |
| 165 | }, | |
| 166 | ) | |
| 167 | .await?; | |
| 168 | Ok(permission | |
| 169 | .into_result() | |
| 170 | .ok() | |
| 171 | .and_then(|info| info.role) | |
| 172 | .is_some_and(|role| access::allows(role, Capability::Push))) | |
| 173 | } | |
| 174 | ||
| 175 | async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 176 | Ok(self.pushed_commits(repo, actor, after, before).await?.0) |
| 177 | } | |
| 178 | ||
| 179 | /// The commits a push added, oldest first, and whether it was forced: | |
| 180 | /// `before` is not in `after`'s history. Known only when that history | |
| 181 | /// is read whole (it is shorter than the [`PUSH_LOG`] commits read); | |
| 182 | /// a longer one says not forced. | |
| 183 | async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 184 | let log: Outcome<Vec<Commit>> = g1t_kit::call( |
| 185 | &self.repos, | |
| 186 | "log", | |
| 187 | &LogArgs { | |
| 188 | path: RepoPath { | |
| 189 | namespace: repo.namespace.clone(), | |
| 190 | name: repo.name.clone(), | |
| 191 | }, | |
| 192 | viewer: Some(actor.clone()), | |
| 193 | git_ref: Some(after.to_owned()), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 194 | limit: PUSH_LOG, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 195 | }, |
| 196 | ) | |
| 197 | .await?; | |
| 198 | let mut commits: Vec<Commit> = log.into_result().unwrap_or_default(); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 199 | let whole = commits.len() < PUSH_LOG as usize; |
| 200 | let mut forced = false; | |
| 201 | if let Some(before) = before.filter(|before| *before != payload::ZERO) { | |
| 202 | match commits.iter().position(|commit| commit.hash == before) { | |
| 203 | Some(at) => commits.truncate(at), | |
| 204 | None => forced = whole && !commits.is_empty(), | |
| 205 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 206 | } |
| 207 | // GitHub lists them oldest first, with the head commit last. | |
| 208 | commits.reverse(); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 209 | Ok((commits, forced)) |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 210 | } |
| 211 | ||
| What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so. | 212 | /// The message of the commit `sha` in `path`, when it can be read. |
| 213 | async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 214 | Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message)) |
| 215 | } | |
| 216 | ||
| 217 | /// The commit `sha` in `path`, when it can be read. | |
| 218 | async fn commit_at(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<Commit>> { | |
| What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so. | 219 | let log: Outcome<Vec<Commit>> = g1t_kit::call( |
| 220 | &self.repos, | |
| 221 | "log", | |
| 222 | &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 }, | |
| 223 | ) | |
| 224 | .await?; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 225 | Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next())) |
| What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so. | 226 | } |
| 227 | ||
| 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. | 228 | /// What `head` changes from `base`: each path and how. |
| 229 | async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 230 | let compared: Outcome<Comparison> = g1t_kit::call( |
| 231 | &self.repos, | |
| 232 | "compare", | |
| 233 | &CompareArgs { | |
| 234 | repo_id: repo.id.clone(), | |
| 235 | viewer: Some(actor.clone()), | |
| 236 | base, | |
| 237 | head: Some(head), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 238 | base_branch: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 239 | }, |
| 240 | ) | |
| 241 | .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. | 242 | Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default()) |
| 243 | } | |
| 244 | ||
| 245 | /// The repository's labels, for their colors, when `payload` has any. | |
| 246 | async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> { | |
| 247 | if !payload::has_labels(payload) { | |
| 248 | return Ok(()); | |
| 249 | } | |
| 250 | let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 }; | |
| 251 | let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?; | |
| 252 | if let Outcome::Ok(labels) = labels { | |
| 253 | payload::color_labels(payload, &labels); | |
| 254 | } | |
| 255 | Ok(()) | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 256 | } |
| 257 | ||
| 258 | async fn default_head(&self, repo: &Repo) -> Result<Option<String>> { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 259 | self.branch_head(repo, &repo.default_branch).await |
| 260 | } | |
| 261 | ||
| 262 | /// The commit `branch` points to now. | |
| 263 | async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 264 | g1t_kit::call( |
| 265 | &self.repos, | |
| 266 | "head", | |
| 267 | &g1t_contracts::repos::HeadArgs { | |
| 268 | repo_id: repo.id.clone(), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 269 | branch: branch.to_owned(), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 270 | }, |
| 271 | ) | |
| 272 | .await | |
| 273 | } | |
| 274 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 275 | /// The pull request's test merge commit for `head` (repos |
| 276 | /// `pull_merge_ref`), made or moved now. A failure to ask is | |
| 277 | /// unavailable, so that its runs still start, on the head. | |
| 278 | async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef { | |
| 279 | let asked: Result<PullMergeRef> = g1t_kit::call( | |
| 280 | &self.repos, | |
| 281 | "pull_merge_ref", | |
| 282 | &PullMergeRefArgs { | |
| 283 | repo_id: repo.id.clone(), | |
| 284 | source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()), | |
| 285 | head: head.to_owned(), | |
| 286 | number: pull.number, | |
| 287 | target_branch: Some(base_ref.to_owned()), | |
| 288 | }, | |
| 289 | ) | |
| 290 | .await; | |
| 291 | asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() }) | |
| 292 | } | |
| 293 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 294 | /// Whether `name` is one of the repository's branches. |
| 295 | async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> { | |
| 296 | let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call( | |
| 297 | &self.repos, | |
| 298 | "branches", | |
| 299 | &g1t_contracts::repos::BranchesArgs { | |
| 300 | path: Self::repo_path(repo), | |
| 301 | viewer: Some(ws.clone()), | |
| 302 | }, | |
| 303 | ) | |
| 304 | .await?; | |
| 305 | Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name)) | |
| 306 | } | |
| 307 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 308 | fn repo_path(repo: &Repo) -> RepoPath { |
| 309 | RepoPath { | |
| 310 | namespace: repo.namespace.clone(), | |
| 311 | name: repo.name.clone(), | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | /// The subject of an event of `kind`, as GitHub's `event_name`. | |
| 316 | async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> { | |
| 317 | let path = Self::repo_path(repo); | |
| 318 | let data = &event.data; | |
| 319 | let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject { | |
| 320 | source: path.clone(), | |
| 321 | source_ref: None, | |
| 322 | git_ref: format!("refs/heads/{}", repo.default_branch), | |
| 323 | sha, | |
| 324 | head_ref: None, | |
| 325 | base_ref: None, | |
| 326 | pull, | |
| 327 | filter_ref: format!("refs/heads/{}", repo.default_branch), | |
| 328 | paths: None, | |
| 329 | compare: None, | |
| 330 | payload, | |
| 331 | title, | |
| 332 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 333 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 334 | merge: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 335 | }; |
| 336 | let view = |number: u32| ViewArgs { | |
| 337 | repo: path.clone(), | |
| 338 | number, | |
| 339 | viewer: Some(ws.clone()), | |
| 340 | after_seq: 0, | |
| 341 | }; | |
| 342 | Ok(match event_name { | |
| 343 | "push" => { | |
| 344 | let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else { | |
| 345 | return Ok(None); | |
| 346 | }; | |
| 347 | // The merge queue's states run merge_group workflows, not push ones. | |
| 348 | if git_ref.starts_with("refs/heads/g1t-queue/") { | |
| 349 | return Ok(None); | |
| 350 | } | |
| 351 | let before = data["before"].as_str(); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 352 | let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?; |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 353 | let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default(); |
| 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. | 354 | let email = pusher_email(event.actor.as_deref(), sender); |
| 355 | let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced); | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 356 | Some(Subject { |
| 357 | source: path.clone(), | |
| 358 | source_ref: Some(after.to_owned()), | |
| 359 | git_ref: git_ref.to_owned(), | |
| 360 | sha: after.to_owned(), | |
| 361 | head_ref: None, | |
| 362 | base_ref: None, | |
| 363 | pull: None, | |
| 364 | filter_ref: git_ref.to_owned(), | |
| 365 | paths: None, | |
| 366 | compare: Some((before.map(str::to_owned), after.to_owned())), | |
| 367 | payload, | |
| 368 | title, | |
| 369 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 370 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 371 | merge: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 372 | }) |
| 373 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 374 | // A branch or tag was made: its own commit, as on GitHub. |
| 375 | "create" => { | |
| 376 | let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else { | |
| 377 | return Ok(None); | |
| 378 | }; | |
| 379 | if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() { | |
| 380 | return Ok(None); | |
| 381 | } | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 382 | let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) }; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 383 | let payload = json!({ |
| 384 | "ref": name, | |
| 385 | "ref_type": ref_type, | |
| 386 | "master_branch": repo.default_branch, | |
| 387 | "description": repo.description, | |
| 388 | "pusher_type": "user", | |
| 389 | "repository": payload::repository(repo), | |
| 390 | "sender": payload::user(sender), | |
| 391 | }); | |
| 392 | Some(Subject { | |
| 393 | source: path.clone(), | |
| 394 | source_ref: Some(after.to_owned()), | |
| 395 | git_ref: git_ref.to_owned(), | |
| 396 | sha: after.to_owned(), | |
| 397 | head_ref: None, | |
| 398 | base_ref: None, | |
| 399 | pull: None, | |
| 400 | filter_ref: git_ref.to_owned(), | |
| 401 | paths: None, | |
| 402 | compare: None, | |
| 403 | payload, | |
| 404 | title: format!("Created {ref_type} {name}"), | |
| 405 | trusted: true, | |
| 406 | approval: None, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 407 | merge: None, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 408 | }) |
| 409 | } | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 410 | // A branch or tag was deleted: on the default branch's latest |
| 411 | // commit, as on GitHub, since what was deleted has no commit to | |
| 412 | // run on now. | |
| 413 | "delete" => { | |
| 414 | let Some(git_ref) = data["ref"].as_str() else { return Ok(None) }; | |
| 415 | let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) }; | |
| 416 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 417 | let payload = json!({ | |
| 418 | "ref": name, | |
| 419 | "ref_type": ref_type, | |
| 420 | "pusher_type": "user", | |
| 421 | "repository": payload::repository(repo), | |
| 422 | "sender": payload::user(sender), | |
| 423 | }); | |
| 424 | Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None)) | |
| 425 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 426 | "pull_request" | "pull_request_target" | "pull_request_review" => { |
| 427 | let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) }; | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 428 | // Assignees changed but nobody was added: someone was only |
| 429 | // taken off, which is not `assigned`. | |
| 430 | if action == Some("assigned") && nobody_added(data) { | |
| 431 | return Ok(None); | |
| 432 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 433 | let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?; |
| 434 | let Outcome::Ok(detail) = detail else { return Ok(None) }; | |
| 435 | let pull = &detail.pull; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 436 | let base_ref = pull.base_branch(&repo.default_branch).to_owned(); |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 437 | let mut payload = json!({ |
| 438 | "action": action, | |
| 439 | "number": pull.number, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 440 | "pull_request": payload::pull(repo, pull), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 441 | "repository": payload::repository(repo), |
| 442 | "sender": payload::user(sender), | |
| 443 | }); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 444 | payload::changed(repo, &mut payload, data); |
| 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. | 445 | self.label_colors(repo, ws, &mut payload).await?; |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 446 | // `base.sha` is the base branch's tip now, as GitHub gives |
| 447 | // it, not where the two last agreed. | |
| 448 | if let Some(tip) = self.branch_head(repo, &base_ref).await? { | |
| 449 | payload["pull_request"]["base"]["sha"] = json!(tip); | |
| 450 | } | |
| 451 | // A merge's commit, once it has landed. | |
| 452 | if pull.status == g1t_contracts::work::PullStatus::Merged | |
| 453 | && let Some(commit) = data["commit"].as_str() | |
| 454 | { | |
| 455 | payload["pull_request"]["merge_commit_sha"] = json!(commit); | |
| 456 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 457 | if event_name == "pull_request_review" { |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 458 | let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory); |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 459 | payload["review"] = payload::review(repo, pull, review); |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 460 | } |
| 461 | let trusted = self.insider(pull.owner(), repo, ws).await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 462 | // A pull request from outside may wait for approval before |
| 463 | // its head's code runs. `pull_request_target` runs the | |
| 464 | // base's code, and a merged one's run the commit it landed | |
| 465 | // as, so neither waits. | |
| 466 | let approval = if event_name != "pull_request_target" && action != Some("closed") { | |
| 467 | self.approval_needed(repo, pull.owner(), ws).await? | |
| 468 | } else { | |
| 469 | None | |
| 470 | }; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 471 | let head_ref = payload::head_ref(pull); |
| 472 | if event_name == "pull_request_target" { | |
| 473 | // In the base's context: its workflows, its head. | |
| 474 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 475 | let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number)); | |
| 476 | subject.head_ref = Some(head_ref); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 477 | subject.base_ref = Some(base_ref.clone()); |
| 478 | subject.filter_ref = format!("refs/heads/{base_ref}"); | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 479 | subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect()); |
| 480 | return Ok(Some(subject)); | |
| 481 | } | |
| 482 | // A merged pull request's run is on the commit it landed as, | |
| 483 | // in the repository; otherwise on its head, where that is. | |
| 484 | let landed = match (action, data["commit"].as_str()) { | |
| 485 | (Some("closed"), Some(commit)) => Some(commit.to_owned()), | |
| 486 | _ => None, | |
| 487 | }; | |
| 488 | let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) { | |
| 489 | (Some(commit), _, _) => commit.clone(), | |
| 490 | (None, Some(commit), _) => commit.to_owned(), | |
| 491 | (None, None, Some(head)) => head.clone(), | |
| 492 | (None, None, None) => return Ok(None), | |
| 493 | }; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 494 | // Otherwise it builds the head merged into the base's tip, |
| 495 | // as on GitHub: the test merge commit at refs/pull/<n>/merge, | |
| 496 | // in the repository, whose workflows it reads. | |
| 497 | let merge = match landed { | |
| 498 | Some(_) => None, | |
| 499 | None => { | |
| 500 | let made = self.test_merge(repo, pull, &sha, &base_ref).await; | |
| 501 | match pull_build(event_name, action, &made) { | |
| 502 | PullBuild::Merge(merge) => Some(merge), | |
| 503 | PullBuild::Nothing => { | |
| 504 | worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number); | |
| 505 | return Ok(None); | |
| 506 | } | |
| 507 | PullBuild::Head => { | |
| 508 | worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number); | |
| 509 | None | |
| 510 | } | |
| 511 | } | |
| 512 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 513 | }; |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 514 | if let Some(merge) = &merge { |
| 515 | payload["pull_request"]["merge_commit_sha"] = json!(merge); | |
| 516 | } | |
| 517 | let (source, source_ref) = match (&landed, &merge) { | |
| 518 | (Some(_), _) => (path.clone(), sha.clone()), | |
| 519 | (None, Some(merge)) => (path.clone(), merge.clone()), | |
| 520 | (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()), | |
| 521 | }; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 522 | Some(Subject { |
| 523 | source, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 524 | source_ref: Some(source_ref), |
| 525 | merge, | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 526 | git_ref: format!("refs/pull/{}/merge", pull.number), |
| 527 | sha, | |
| 528 | head_ref: Some(head_ref), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 529 | base_ref: Some(base_ref.clone()), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 530 | pull: Some(pull.number), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 531 | // `branches` filters on pull requests name the base. |
| 532 | filter_ref: format!("refs/heads/{base_ref}"), | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 533 | paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()), |
| 534 | compare: None, | |
| 535 | payload, | |
| 536 | title: pull.title.clone(), | |
| 537 | trusted, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 538 | approval, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 539 | }) |
| 540 | } | |
| 541 | "workflow_run" => { | |
| 542 | // A run of a workflow_run workflow does not start another, | |
| 543 | // so two such workflows cannot set each other off. | |
| 544 | if data["event"].as_str() == Some("workflow_run") { | |
| 545 | return Ok(None); | |
| 546 | } | |
| 547 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 548 | let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned(); | |
| 549 | let name = data["workflow"].as_str().unwrap_or_default(); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 550 | // The followed run's row, for what the event does not carry. |
| 551 | let row = match data["runId"].as_str() { | |
| 552 | Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?, | |
| 553 | None => None, | |
| 554 | }; | |
| 555 | let head_commit = match data["sha"].as_str() { | |
| 556 | Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head), | |
| 557 | None => None, | |
| 558 | }; | |
| 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. | 559 | // Its pull request, for `pull_requests[]`. |
| 560 | let pull = match data["pull"].as_u64() { | |
| 561 | Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? { | |
| 562 | Outcome::Ok(detail) => Some(detail.pull), | |
| 563 | Outcome::Fail(_) => None, | |
| 564 | }, | |
| 565 | None => None, | |
| 566 | }; | |
| 567 | let base_sha = match &pull { | |
| 568 | Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?, | |
| 569 | _ => None, | |
| 570 | }; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 571 | let followed = payload::FollowedRun { |
| 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. | 572 | pull: pull.as_ref(), |
| 573 | base_sha: base_sha.as_deref(), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 574 | data, |
| 575 | workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()), | |
| 576 | attempt: row.as_ref().map(|r| r.attempt), | |
| 577 | created_at: row.as_ref().map(|r| r.created_at.as_str()), | |
| 578 | started_at: row.as_ref().and_then(|r| r.started_at.as_deref()), | |
| 579 | updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()), | |
| 580 | actor: row.as_ref().and_then(|r| r.actor.as_deref()), | |
| 581 | triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()), | |
| 582 | title: row.as_ref().map(|r| r.title.as_str()), | |
| 583 | head_commit: head_commit.as_ref(), | |
| 584 | }; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 585 | let payload = json!({ |
| 586 | "action": "completed", | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 587 | "workflow_run": payload::workflow_run(repo, &followed), |
| 588 | "workflow": { | |
| 589 | "id": followed.workflow_id, | |
| 590 | "node_id": followed.workflow_id, | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 591 | "name": name, |
| 592 | "path": data["path"], | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 593 | "state": "active", |
| 594 | "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name), | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 595 | }, |
| 596 | "repository": payload::repository(repo), | |
| 597 | "sender": payload::user(sender), | |
| 598 | }); | |
| 599 | let mut subject = on_default(sha, payload, format!("After {name}"), None); | |
| 600 | // Branch filters apply to the branch the followed run was on. | |
| 601 | subject.filter_ref = format!("refs/heads/{head_branch}"); | |
| 602 | Some(subject) | |
| 603 | } | |
| 604 | "issues" | "issue_comment" => { | |
| 605 | let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) }; | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 606 | if action == Some("assigned") && nobody_added(data) { |
| 607 | return Ok(None); | |
| 608 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 609 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; |
| 610 | let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?; | |
| 611 | let (issue_json, comments, title, on_pull) = match issue { | |
| 612 | Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false), | |
| 613 | Outcome::Fail(_) => { | |
| 614 | let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?; | |
| 615 | let Outcome::Ok(detail) = pull else { return Ok(None) }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 616 | (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true) |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 617 | } |
| 618 | }; | |
| 619 | let mut payload = json!({ | |
| 620 | "action": action, | |
| 621 | "issue": issue_json, | |
| 622 | "repository": payload::repository(repo), | |
| 623 | "sender": payload::user(sender), | |
| 624 | }); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 625 | payload::changed(repo, &mut payload, data); |
| 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. | 626 | self.label_colors(repo, ws, &mut payload).await?; |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 627 | if event_name == "issue_comment" { |
| 628 | let comment_id = data["commentId"].as_str(); | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 629 | if action == Some("deleted") { |
| 630 | // Gone by now: as the event kept it. | |
| 631 | match data.get("comment").filter(|kept| kept.is_object()) { | |
| 632 | Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull), | |
| 633 | None => return Ok(None), | |
| 634 | } | |
| 635 | } else { | |
| 636 | let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id); | |
| 637 | // A new comment is the newest; an edited one must be found. | |
| 638 | let comment = if action == Some("created") { comment.or(comments.last()) } else { comment }; | |
| 639 | match comment { | |
| 640 | Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull), | |
| 641 | None => return Ok(None), | |
| 642 | } | |
| 643 | } | |
| 644 | // `edited`: what the body was before. | |
| 645 | if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) { | |
| 646 | payload["changes"] = changes.clone(); | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 647 | } |
| 648 | } | |
| 649 | Some(on_default(sha, payload, title, on_pull.then_some(number))) | |
| 650 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 651 | // A release: on its tag, at the commit the tag named. |
| 652 | "release" => { | |
| 653 | let release = &data["release"]; | |
| 654 | let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) }; | |
| 655 | let sha = match release["target"].as_str().filter(|target| !target.is_empty()) { | |
| 656 | Some(target) => target.to_owned(), | |
| 657 | None => match self.default_head(repo).await? { | |
| 658 | Some(head) => head, | |
| 659 | None => return Ok(None), | |
| 660 | }, | |
| 661 | }; | |
| 662 | let git_ref = format!("refs/tags/{tag}"); | |
| 663 | let mut payload = json!({ | |
| 664 | "action": action, | |
| 665 | "release": payload::release(repo, release), | |
| 666 | "repository": payload::repository(repo), | |
| 667 | "sender": payload::user(sender), | |
| 668 | }); | |
| 669 | if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) { | |
| 670 | payload["changes"] = changes.clone(); | |
| 671 | } | |
| 672 | let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag); | |
| 673 | Some(Subject { | |
| 674 | source: path.clone(), | |
| 675 | source_ref: Some(sha.clone()), | |
| 676 | git_ref: git_ref.clone(), | |
| 677 | sha, | |
| 678 | head_ref: None, | |
| 679 | base_ref: None, | |
| 680 | pull: None, | |
| 681 | filter_ref: git_ref, | |
| 682 | paths: None, | |
| 683 | compare: None, | |
| 684 | payload, | |
| 685 | title: format!("Release {name} {}", action.unwrap_or("changed")), | |
| 686 | trusted: true, | |
| 687 | approval: None, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 688 | merge: None, |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 689 | }) |
| 690 | } | |
| 691 | // A deployment, or a new status of one: at the commit deployed, | |
| 692 | // on the branch or tag it names (none for a bare commit). | |
| 693 | "deployment" | "deployment_status" => { | |
| 694 | let deployment = &data["deployment"]; | |
| 695 | let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) }; | |
| 696 | let named = deployment["ref"].as_str().unwrap_or_default(); | |
| 697 | let git_ref = if named.is_empty() || named == sha || is_commit(named) { | |
| 698 | String::new() | |
| 699 | } else if named.starts_with("refs/") { | |
| 700 | named.to_owned() | |
| 701 | } else if self.is_branch(repo, ws, named).await? { | |
| 702 | format!("refs/heads/{named}") | |
| 703 | } else { | |
| 704 | format!("refs/tags/{named}") | |
| 705 | }; | |
| 706 | let environment = deployment["environment"].as_str().unwrap_or_default(); | |
| 707 | let mut payload = json!({ | |
| 708 | "action": "created", | |
| 709 | "deployment": payload::deployment(repo, deployment), | |
| 710 | "repository": payload::repository(repo), | |
| 711 | "sender": payload::user(sender), | |
| 712 | }); | |
| 713 | let title = if event_name == "deployment_status" { | |
| 714 | let status = &data["deploymentStatus"]; | |
| 715 | payload["deployment_status"] = payload::deployment_status(repo, status, deployment); | |
| 716 | format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed")) | |
| 717 | } else { | |
| 718 | format!("Deployment to {environment}") | |
| 719 | }; | |
| 720 | Some(Subject { | |
| 721 | source: path.clone(), | |
| 722 | source_ref: Some(sha.clone()), | |
| 723 | filter_ref: git_ref.clone(), | |
| 724 | git_ref, | |
| 725 | sha, | |
| 726 | head_ref: None, | |
| 727 | base_ref: None, | |
| 728 | pull: None, | |
| 729 | paths: None, | |
| 730 | compare: None, | |
| 731 | payload, | |
| 732 | title, | |
| 733 | trusted: true, | |
| 734 | approval: None, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 735 | merge: None, |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 736 | }) |
| 737 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 738 | _ => None, |
| 739 | }) | |
| 740 | } | |
| 741 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 742 | /// A push keeps the repository's schedules going (`run_schedules`). |
| 743 | /// Written at most once a day, and only on scheduled workflows. | |
| 744 | async fn note_push(&self, repo_id: &str) -> Result<()> { | |
| 745 | let at = now_ms(); | |
| 746 | self.db | |
| 747 | .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)") | |
| 748 | .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])? | |
| 749 | .run() | |
| 750 | .await?; | |
| 751 | Ok(()) | |
| 752 | } | |
| 753 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 754 | pub async fn on_event(&self, event: &Event) -> Result<()> { |
| 755 | let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 756 | let mut mapped = github_events(&event.kind); |
| 757 | // A new branch or tag is also `create`. | |
| 758 | if event.kind == "git.push" && event.data["before"].is_null() { | |
| 759 | mapped.push(("create", None)); | |
| 760 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 761 | let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true); |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 762 | if event.kind == "git.push" { |
| 763 | self.note_push(repo_id).await?; | |
| 764 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 765 | if mapped.is_empty() && !pushed_default { |
| 766 | return Ok(()); | |
| 767 | } | |
| 768 | let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) }; | |
| 769 | if pushed_default { | |
| 770 | self.sync(&repo, &ws).await?; | |
| 771 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 772 | // What a workflow job's own token did starts no workflows, as on |
| 773 | // GitHub, so a workflow cannot set itself off; only | |
| 774 | // `workflow_dispatch` and `repository_dispatch` do. | |
| 775 | if let Some(run) = caused_by_job(&event.data) { | |
| 776 | worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id); | |
| 777 | return Ok(()); | |
| 778 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 779 | let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone()); |
| 780 | for (event_name, action) in mapped { | |
| 781 | // Issues and comments start the default branch's workflows, | |
| 782 | // which the synced table lists: when none listens, nothing is | |
| 783 | // read from git. Agents make many of these events. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 784 | // Deployments' statuses are as frequent (every g1t.page build |
| 785 | // reports several), so they look there first too. | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 786 | if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete") |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 787 | && self.listens(repo_id, event_name).await? == Some(false) |
| 788 | { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 789 | continue; |
| 790 | } | |
| 791 | let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else { | |
| 792 | continue; | |
| 793 | }; | |
| What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so. | 794 | // `[skip ci]` in the head commit's message, as on GitHub. |
| 795 | if matches!(event_name, "push" | "pull_request") { | |
| 796 | let message = match subject.payload["head_commit"]["message"].as_str() { | |
| 797 | Some(message) => Some(message.to_owned()), | |
| 798 | None => self.head_message(&subject.source, &ws, &subject.sha).await?, | |
| 799 | }; | |
| 800 | if message.as_deref().is_some_and(g1t_actions::events::skips_ci) { | |
| 801 | worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha); | |
| 802 | continue; | |
| Merge branch 'mirroring' into artifacts-mode | 803 | } |
| 804 | } | |
| 805 | // A pull request from a fork reads the fork's files. | |
| 806 | let read = if subject.source == Self::repo_path(&repo) { | |
| 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. | 807 | self.read_for(&repo, &ws, subject.source_ref.as_deref()).await? |
| Merge branch 'mirroring' into artifacts-mode | 808 | } else { |
| A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so. | 809 | // And this repository's managed workflows: they are its own, |
| 810 | // whatever the fork's files say (managed.rs). | |
| 811 | let mut read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).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. | 812 | read.files = self.with_managed(&repo, read.files).await?; |
| A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so. | 813 | read |
| Merge branch 'mirroring' into artifacts-mode | 814 | }; |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 815 | // A pull request's head runs each workflow once, however many |
| 816 | // events say it is there (marked ready, and pushed). | |
| 817 | let key = match subject.pull { | |
| 818 | Some(number) if event_name.starts_with("pull_request") && event_name != "pull_request_review" => { | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 819 | // Reopened or made a draft again runs anew, at a head |
| 820 | // that may have run before. | |
| 821 | let phase = match action { | |
| 822 | Some("closed") => "closed".to_owned(), | |
| 823 | Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id), | |
| 824 | _ => "open".to_owned(), | |
| 825 | }; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 826 | format!("{event_name}:{number}:{}:{phase}", subject.sha) |
| 827 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 828 | _ if event_name == "create" => format!("{}:create", event.id), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 829 | _ => event.id.clone(), |
| 830 | }; | |
| 831 | self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender) | |
| 832 | .await?; | |
| 833 | } | |
| 834 | Ok(()) | |
| 835 | } | |
| 836 | ||
| 837 | #[allow(clippy::too_many_arguments)] | |
| 838 | async fn start_matching( | |
| 839 | &self, | |
| 840 | repo: &Repo, | |
| 841 | ws: &User, | |
| 842 | read: Read, | |
| 843 | subject: &mut Subject, | |
| 844 | event_name: &str, | |
| 845 | action: Option<&str>, | |
| 846 | event_key: &str, | |
| 847 | actor_id: Option<&str>, | |
| 848 | sender: &str, | |
| 849 | ) -> Result<()> { | |
| 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. | 850 | let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect(); |
| 851 | // A push some workflow filters by path is compared before any run | |
| 852 | // starts, so every run's payload lists what its commit changed. | |
| 853 | let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| { | |
| 854 | parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set()) | |
| 855 | }; | |
| 856 | if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) { | |
| 857 | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 858 | subject.changed(self.changed_paths(repo, ws, base, head).await?); | |
| 859 | } | |
| 860 | for (parsed, file) in parsed { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 861 | let workflow = match parsed { |
| 862 | Ok(workflow) => workflow, | |
| 863 | Err(problem) => { | |
| 864 | // A push shows a broken workflow as a failed run, as GitHub does. | |
| 865 | if event_name == "push" && file.source.contains("on") { | |
| 866 | self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem) | |
| 867 | .await?; | |
| 868 | } | |
| 869 | continue; | |
| 870 | } | |
| 871 | }; | |
| 872 | let Some(trigger) = workflow.trigger(event_name) else { continue }; | |
| 873 | // workflow_run follows the workflows it names. | |
| 874 | if event_name == "workflow_run" { | |
| 875 | let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default(); | |
| 876 | if !trigger.workflows.iter().any(|name| name == followed) { | |
| 877 | continue; | |
| 878 | } | |
| 879 | } | |
| 880 | if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? { | |
| 881 | continue; | |
| 882 | } | |
| 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. | 883 | // A workspace's required workflow runs whatever its row says. |
| 884 | if !file.required && self.disabled(&repo.id, &file.path).await? { | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 885 | continue; |
| 886 | } | |
| 887 | self.create_run(NewRun { | |
| 888 | repo: repo.clone(), | |
| 889 | path: file.path, | |
| 890 | source: file.source, | |
| 891 | info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id), | |
| 892 | workflow, | |
| 893 | action: action.map(str::to_owned), | |
| 894 | pull: subject.pull, | |
| 895 | title: subject.title.clone(), | |
| 896 | inputs: Map::new(), | |
| 897 | event_key: event_key.to_owned(), | |
| 898 | actor_id: actor_id.map(str::to_owned), | |
| 899 | actor: Some(sender.to_owned()), | |
| 900 | trusted: subject.trusted, | |
| 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. | 901 | approval: subject.approval.clone(), |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 902 | head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 903 | }) |
| 904 | .await?; | |
| 905 | } | |
| 906 | Ok(()) | |
| 907 | } | |
| 908 | ||
| 909 | /// Whether the branch, tag and path filters let the event through. | |
| 910 | async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> { | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 911 | // GitHub takes no filters on these: a workflow with `branches` on |
| 912 | // `create` or `delete` still runs for every branch and tag. | |
| 913 | if unfiltered(event_name) { | |
| 914 | return Ok(true); | |
| 915 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 916 | let git_ref = subject.filter_ref.as_str(); |
| 917 | if let Some(tag) = git_ref.strip_prefix("refs/tags/") { | |
| 918 | // A tag push runs a workflow that filters tags, or filters nothing. | |
| 919 | if trigger.tags.is_set() { | |
| 920 | if !trigger.tags.allows(tag) { | |
| 921 | return Ok(false); | |
| 922 | } | |
| 923 | } else if trigger.branches.is_set() { | |
| 924 | return Ok(false); | |
| 925 | } | |
| 926 | // Paths are not checked for tags, as on GitHub. | |
| 927 | return Ok(true); | |
| 928 | } | |
| 929 | let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref); | |
| 930 | if trigger.branches.is_set() { | |
| 931 | if !trigger.branches.allows(branch) { | |
| 932 | return Ok(false); | |
| 933 | } | |
| 934 | } else if event_name == "push" && trigger.tags.is_set() { | |
| 935 | return Ok(false); | |
| 936 | } | |
| 937 | if trigger.paths.is_set() { | |
| 938 | if subject.paths.is_none() { | |
| 939 | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 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. | 940 | subject.changed(self.changed_paths(repo, ws, base, head).await?); |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 941 | } |
| 942 | if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) { | |
| 943 | return Ok(false); | |
| 944 | } | |
| 945 | } | |
| 946 | Ok(true) | |
| 947 | } | |
| 948 | ||
| 949 | async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> { | |
| 950 | let row = self | |
| 951 | .db | |
| 952 | .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?") | |
| 953 | .bind(&[repo_id.into(), path.into()])? | |
| 954 | .first::<WorkflowRow>(None) | |
| 955 | .await?; | |
| 956 | Ok(row.is_some_and(|row| row.state == "disabled")) | |
| 957 | } | |
| 958 | ||
| 959 | fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo { | |
| 960 | RunInfo { | |
| 961 | repository: format!("{}/{}", repo.namespace, repo.name), | |
| 962 | repository_id: repo.id.clone(), | |
| 963 | default_branch: repo.default_branch.clone(), | |
| 964 | event_name: event_name.to_owned(), | |
| 965 | event: subject.payload.clone(), | |
| 966 | git_ref: subject.git_ref.clone(), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 967 | sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()), |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 968 | head_ref: subject.head_ref.clone(), |
| 969 | base_ref: subject.base_ref.clone(), | |
| 970 | actor: sender.to_owned(), | |
| 971 | actor_id: actor_id.unwrap_or_default().to_owned(), | |
| 972 | triggering_actor: sender.to_owned(), | |
| 973 | run_id: String::new(), | |
| 974 | run_number: 0, | |
| 975 | run_attempt: 1, | |
| 976 | workflow: workflow.name.clone().unwrap_or_default(), | |
| 977 | workflow_path: String::new(), | |
| 978 | server_url: SITE.to_owned(), | |
| 979 | api_url: API.to_owned(), | |
| 980 | } | |
| 981 | } | |
| 982 | ||
| 983 | #[allow(clippy::too_many_arguments)] | |
| 984 | async fn record_invalid( | |
| 985 | &self, | |
| 986 | repo: &Repo, | |
| 987 | path: &str, | |
| 988 | source: &str, | |
| 989 | subject: &Subject, | |
| 990 | event_key: &str, | |
| 991 | actor_id: Option<&str>, | |
| 992 | sender: &str, | |
| 993 | problem: &str, | |
| 994 | ) -> Result<()> { | |
| 995 | let row = self.workflow_row(repo, path, path, source).await?; | |
| 996 | self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await | |
| 997 | } | |
| 998 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 999 | /// Scheduled workflows that run this minute, on the default branch: at |
| 1000 | /// most every five minutes (`cron::Schedule::runs_at`). Not in a | |
| 1001 | /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an | |
| 1002 | /// hour after billing refused one of the workflow's scheduled jobs | |
| 1003 | /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused. | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1004 | pub async fn run_schedules(&self, minute: u64) -> Result<()> { |
| 1005 | let rows = self | |
| 1006 | .db | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1007 | .prepare( |
| 1008 | "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL | |
| 1009 | AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)", | |
| 1010 | ) | |
| 1011 | .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])? | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1012 | .all() |
| 1013 | .await? | |
| 1014 | .results::<WorkflowRow>()?; | |
| 1015 | for row in rows { | |
| 1016 | let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default(); | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1017 | let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else { |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1018 | continue; |
| 1019 | }; | |
| 1020 | let Ok(workflow) = workflow::parse(&row.source) else { continue }; | |
| 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. | 1021 | // Schedules wait while a repository is archived; a deleted one is |
| 1022 | // not found. | |
| Merge branch 'mirroring' into artifacts-mode | 1023 | let Some((repo, _ws)) = self |
| 1024 | .repo_by_id(&row.repo_id) | |
| 1025 | .await? | |
| 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. | 1026 | .filter(|(repo, _)| !repo.archived()) |
| Merge branch 'mirroring' into artifacts-mode | 1027 | else { |
| 1028 | continue; | |
| 1029 | }; | |
| 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. | 1030 | // A managed workflow's row may be out of date while its |
| 1031 | // repository waits to be synced again (managed.rs). | |
| 1032 | if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? { | |
| 1033 | continue; | |
| 1034 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1035 | let Some(sha) = self.default_head(&repo).await? else { continue }; |
| 1036 | let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path }); | |
| 1037 | let mut subject = Subject { | |
| 1038 | source: Self::repo_path(&repo), | |
| 1039 | source_ref: None, | |
| 1040 | git_ref: format!("refs/heads/{}", repo.default_branch), | |
| 1041 | sha, | |
| 1042 | head_ref: None, | |
| 1043 | base_ref: None, | |
| 1044 | pull: None, | |
| 1045 | filter_ref: String::new(), | |
| 1046 | paths: None, | |
| 1047 | compare: None, | |
| 1048 | payload, | |
| 1049 | title: format!("Scheduled: {cron}"), | |
| 1050 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1051 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1052 | merge: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1053 | }; |
| 1054 | subject.filter_ref = subject.git_ref.clone(); | |
| 1055 | let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None); | |
| 1056 | self.create_run(NewRun { | |
| 1057 | repo: repo.clone(), | |
| 1058 | path: row.path.clone(), | |
| 1059 | source: row.source.clone(), | |
| 1060 | workflow, | |
| 1061 | info, | |
| 1062 | action: None, | |
| 1063 | pull: None, | |
| 1064 | title: subject.title.clone(), | |
| 1065 | inputs: Map::new(), | |
| 1066 | event_key: format!("schedule:{minute}"), | |
| 1067 | actor_id: None, | |
| 1068 | actor: None, | |
| 1069 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1070 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1071 | head_sha: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1072 | }) |
| 1073 | .await?; | |
| 1074 | } | |
| 1075 | Ok(()) | |
| 1076 | } | |
| 1077 | ||
| 1078 | /// `dispatch`: someone with the Write role runs a workflow that has | |
| 1079 | /// `workflow_dispatch`. | |
| 1080 | pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> { | |
| 1081 | let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?); | |
| 1082 | if repo.archived() { | |
| 1083 | return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name))); | |
| 1084 | } | |
| 1085 | let Some(ws) = self.workspace_actor(&repo.namespace).await? else { | |
| 1086 | return Ok(fail(FailureCode::NotFound, "There is no such workspace.")); | |
| 1087 | }; | |
| 1088 | let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone()); | |
| 1089 | let full_ref = if git_ref.starts_with("refs/") { | |
| 1090 | git_ref.clone() | |
| 1091 | } else { | |
| 1092 | // A branch if there is one by that name, otherwise a tag. | |
| 1093 | let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call( | |
| 1094 | &self.repos, | |
| 1095 | "branches", | |
| 1096 | &g1t_contracts::repos::BranchesArgs { | |
| 1097 | path: Self::repo_path(&repo), | |
| 1098 | viewer: Some(ws.clone()), | |
| 1099 | }, | |
| 1100 | ) | |
| 1101 | .await?; | |
| 1102 | let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref); | |
| 1103 | format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" }) | |
| 1104 | }; | |
| 1105 | let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned(); | |
| 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. | 1106 | let read = self.read_for(&repo, &ws, Some(&short)).await?; |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1107 | let Some(sha) = read.head.clone() else { |
| 1108 | return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}."))); | |
| 1109 | }; | |
| 1110 | // A workflow is named by its file (`build.yml`), its path, or its id | |
| 1111 | // (`wfl_…`), which stands for the path it was read from. | |
| 1112 | let by_id = if a.workflow.starts_with("wfl_") { | |
| 1113 | self.db | |
| 1114 | .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?") | |
| 1115 | .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])? | |
| 1116 | .first::<WorkflowRow>(None) | |
| 1117 | .await? | |
| 1118 | .map(|row| row.path) | |
| 1119 | } else { | |
| 1120 | None | |
| 1121 | }; | |
| 1122 | let named = by_id.as_deref().unwrap_or(&a.workflow); | |
| 1123 | let wanted = named.trim_start_matches(".g1t/workflows/"); | |
| 1124 | let Some(file) = read.files.iter().find(|file| { | |
| 1125 | file.path.rsplit('/').next() == Some(wanted) || file.path == named | |
| 1126 | }) else { | |
| 1127 | return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}."))); | |
| 1128 | }; | |
| 1129 | let workflow = match workflow::parse(&file.source) { | |
| 1130 | Ok(workflow) => workflow, | |
| 1131 | Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))), | |
| 1132 | }; | |
| 1133 | let Some(trigger) = workflow.trigger("workflow_dispatch") else { | |
| 1134 | return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger.")); | |
| 1135 | }; | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1136 | // `type: environment` names one of the repository's environments. |
| 1137 | let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) { | |
| 1138 | self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect() | |
| 1139 | } else { | |
| 1140 | Vec::new() | |
| 1141 | }; | |
| 1142 | let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments)); | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1143 | let payload = json!({ |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1144 | // As GitHub gives them here: text, `true` and `false` included. |
| 1145 | // The `inputs` context keeps booleans and numbers. | |
| 1146 | "inputs": event_inputs(&inputs), | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1147 | "ref": full_ref, |
| 1148 | "repository": payload::repository(&repo), | |
| 1149 | "sender": payload::user(&a.actor.username), | |
| 1150 | "workflow": file.path, | |
| 1151 | }); | |
| 1152 | let subject = Subject { | |
| 1153 | source: Self::repo_path(&repo), | |
| 1154 | source_ref: Some(sha.clone()), | |
| 1155 | git_ref: full_ref.clone(), | |
| 1156 | sha, | |
| 1157 | head_ref: None, | |
| 1158 | base_ref: None, | |
| 1159 | pull: None, | |
| 1160 | filter_ref: full_ref, | |
| 1161 | paths: None, | |
| 1162 | compare: None, | |
| 1163 | payload, | |
| 1164 | title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username), | |
| 1165 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1166 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1167 | merge: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1168 | }; |
| 1169 | let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id)); | |
| 1170 | let created = self | |
| 1171 | .create_run(NewRun { | |
| 1172 | repo: repo.clone(), | |
| 1173 | path: file.path.clone(), | |
| 1174 | source: file.source.clone(), | |
| 1175 | workflow, | |
| 1176 | info, | |
| 1177 | action: None, | |
| 1178 | pull: None, | |
| 1179 | title: subject.title.clone(), | |
| 1180 | inputs, | |
| 1181 | event_key: format!("dispatch:{}", new_id("dsp", now_ms())), | |
| 1182 | actor_id: Some(a.actor.id.clone()), | |
| 1183 | actor: Some(a.actor.username.clone()), | |
| 1184 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1185 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1186 | head_sha: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1187 | }) |
| 1188 | .await?; | |
| 1189 | match created { | |
| 1190 | Some(id) => self.run_summary(&id).await, | |
| 1191 | None => Ok(fail(FailureCode::Conflict, "It did not start.")), | |
| 1192 | } | |
| 1193 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1194 | |
| 1195 | /// `repository_dispatch`: an outside event, by name, starts the default | |
| 1196 | /// branch's workflows that run `on: repository_dispatch` with that type | |
| 1197 | /// (or with no `types`). A workflow job's token may send one: this, | |
| 1198 | /// with `workflow_dispatch`, is how a workflow starts another. | |
| 1199 | pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> { | |
| 1200 | let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?); | |
| 1201 | if repo.archived() { | |
| 1202 | return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name))); | |
| 1203 | } | |
| 1204 | let event_type = a.event_type.trim().to_owned(); | |
| 1205 | if event_type.is_empty() || event_type.chars().count() > 100 { | |
| 1206 | return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters.")); | |
| 1207 | } | |
| 1208 | let client_payload = match a.client_payload { | |
| 1209 | Value::Null => json!({}), | |
| 1210 | Value::Object(map) if map.len() <= 10 => Value::Object(map), | |
| 1211 | Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")), | |
| 1212 | _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")), | |
| 1213 | }; | |
| 1214 | if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 { | |
| 1215 | return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB.")); | |
| 1216 | } | |
| 1217 | let Some(ws) = self.workspace_actor(&repo.namespace).await? else { | |
| 1218 | return Ok(fail(FailureCode::NotFound, "There is no such workspace.")); | |
| 1219 | }; | |
| 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. | 1220 | let read = self.read_for(&repo, &ws, Some(&repo.default_branch)).await?; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1221 | let Some(sha) = read.head.clone() else { |
| 1222 | return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet.")); | |
| 1223 | }; | |
| 1224 | let git_ref = format!("refs/heads/{}", repo.default_branch); | |
| 1225 | let payload = json!({ | |
| 1226 | "action": event_type, | |
| 1227 | "branch": repo.default_branch, | |
| 1228 | "client_payload": client_payload, | |
| 1229 | "repository": payload::repository(&repo), | |
| 1230 | "sender": payload::user(&a.actor.username), | |
| 1231 | }); | |
| 1232 | let key = format!("repository_dispatch:{}", new_id("dsp", now_ms())); | |
| 1233 | let mut started = 0u32; | |
| 1234 | for file in read.files { | |
| 1235 | let Ok(workflow) = workflow::parse(&file.source) else { continue }; | |
| 1236 | let Some(trigger) = workflow.trigger("repository_dispatch") else { continue }; | |
| 1237 | if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? { | |
| 1238 | continue; | |
| 1239 | } | |
| 1240 | let subject = Subject { | |
| 1241 | source: Self::repo_path(&repo), | |
| 1242 | source_ref: Some(sha.clone()), | |
| 1243 | git_ref: git_ref.clone(), | |
| 1244 | sha: sha.clone(), | |
| 1245 | head_ref: None, | |
| 1246 | base_ref: None, | |
| 1247 | pull: None, | |
| 1248 | filter_ref: git_ref.clone(), | |
| 1249 | paths: None, | |
| 1250 | compare: None, | |
| 1251 | payload: payload.clone(), | |
| 1252 | title: event_type.clone(), | |
| 1253 | trusted: true, | |
| 1254 | approval: None, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1255 | merge: None, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1256 | }; |
| 1257 | let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id)); | |
| 1258 | let created = self | |
| 1259 | .create_run(NewRun { | |
| 1260 | repo: repo.clone(), | |
| 1261 | path: file.path.clone(), | |
| 1262 | source: file.source.clone(), | |
| 1263 | workflow, | |
| 1264 | info, | |
| 1265 | action: Some(event_type.clone()), | |
| 1266 | pull: None, | |
| 1267 | title: subject.title.clone(), | |
| 1268 | inputs: Map::new(), | |
| 1269 | event_key: key.clone(), | |
| 1270 | actor_id: Some(a.actor.id.clone()), | |
| 1271 | actor: Some(a.actor.username.clone()), | |
| 1272 | trusted: true, | |
| 1273 | approval: None, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1274 | head_sha: None, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1275 | }) |
| 1276 | .await?; | |
| 1277 | if created.is_some() { | |
| 1278 | started += 1; | |
| 1279 | } | |
| 1280 | } | |
| 1281 | Ok(Outcome::Ok(started)) | |
| 1282 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1283 | } |
| 1284 | ||
| 1285 | #[derive(serde::Deserialize)] | |
| 1286 | #[serde(rename_all = "camelCase")] | |
| 1287 | pub struct MergeGroupArgs { | |
| 1288 | pub repo_id: String, | |
| 1289 | pub entry: String, | |
| 1290 | pub sha: String, | |
| 1291 | pub head_ref: String, | |
| 1292 | #[serde(default)] | |
| 1293 | pub base_sha: Option<String>, | |
| 1294 | pub number: u32, | |
| 1295 | #[serde(default)] | |
| 1296 | pub ahead: Vec<u32>, | |
| 1297 | } | |
| 1298 | ||
| 1299 | impl Actions { | |
| 1300 | /// `merge_group`: the merge queue built a state and its checks passed. | |
| 1301 | /// Starts the workflows that run `on: merge_group` on it, as GitHub's | |
| 1302 | /// queue does, and says how many started; the queue waits for their | |
| 1303 | /// statuses on that commit. | |
| 1304 | pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> { | |
| 1305 | let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else { | |
| 1306 | return Ok(Outcome::Ok(json!({ "runs": 0 }))); | |
| 1307 | }; | |
| 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. | 1308 | let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?; |
| 1309 | // A required workflow's check holds the queue too (managed.rs). | |
| 1310 | read.files = self.with_managed(&repo, read.files).await?; | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1311 | let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop(); |
| 1312 | let payload = json!({ | |
| 1313 | "action": "checks_requested", | |
| 1314 | "merge_group": { | |
| 1315 | "head_sha": a.sha, | |
| 1316 | "head_ref": a.head_ref, | |
| 1317 | "base_sha": a.base_sha, | |
| 1318 | "base_ref": format!("refs/heads/{}", repo.default_branch), | |
| 1319 | "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)), | |
| 1320 | }, | |
| 1321 | "repository": payload::repository(&repo), | |
| 1322 | "sender": payload::user(&repo.namespace), | |
| 1323 | }); | |
| 1324 | let mut started = 0u32; | |
| 1325 | for file in read.files { | |
| 1326 | let Ok(workflow) = workflow::parse(&file.source) else { continue }; | |
| 1327 | let Some(trigger) = workflow.trigger("merge_group") else { continue }; | |
| 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. | 1328 | if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) { |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1329 | continue; |
| 1330 | } | |
| 1331 | // Branch filters on merge_group name the branch it merges into. | |
| 1332 | if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) { | |
| 1333 | continue; | |
| 1334 | } | |
| 1335 | let ahead = if a.ahead.is_empty() { | |
| 1336 | String::new() | |
| 1337 | } else { | |
| 1338 | format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")) | |
| 1339 | }; | |
| 1340 | let subject = Subject { | |
| 1341 | source: Self::repo_path(&repo), | |
| 1342 | source_ref: Some(a.sha.clone()), | |
| 1343 | git_ref: a.head_ref.clone(), | |
| 1344 | sha: a.sha.clone(), | |
| 1345 | head_ref: None, | |
| 1346 | base_ref: Some(repo.default_branch.clone()), | |
| 1347 | pull: Some(a.number), | |
| 1348 | filter_ref: format!("refs/heads/{}", repo.default_branch), | |
| 1349 | paths: None, | |
| 1350 | compare: None, | |
| 1351 | payload: payload.clone(), | |
| 1352 | title: format!("Merge queue: #{}{ahead}", a.number), | |
| 1353 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1354 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1355 | merge: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1356 | }; |
| 1357 | let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None); | |
| 1358 | let created = self | |
| 1359 | .create_run(NewRun { | |
| 1360 | repo: repo.clone(), | |
| 1361 | path: file.path.clone(), | |
| 1362 | source: file.source.clone(), | |
| 1363 | workflow, | |
| 1364 | info, | |
| 1365 | action: Some("checks_requested".to_owned()), | |
| 1366 | pull: Some(a.number), | |
| 1367 | title: subject.title.clone(), | |
| 1368 | inputs: Map::new(), | |
| 1369 | event_key: format!("merge_group:{}:{}", a.entry, a.sha), | |
| 1370 | actor_id: None, | |
| 1371 | actor: None, | |
| 1372 | trusted: true, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1373 | approval: None, |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1374 | head_sha: None, |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1375 | }) |
| 1376 | .await?; | |
| 1377 | if created.is_some() { | |
| 1378 | started += 1; | |
| 1379 | } | |
| 1380 | } | |
| 1381 | Ok(Outcome::Ok(json!({ "runs": started }))) | |
| 1382 | } | |
| 1383 | } | |
| 1384 | ||
| 1385 | /// The inputs of a manual run: what was given, checked against the | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1386 | /// workflow's declared inputs, with their defaults filled in. An input the |
| 1387 | /// workflow does not declare is refused, as GitHub refuses it. | |
| 1388 | /// `environments` are the repository's, for `type: environment`. | |
| 1389 | fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> { | |
| 1390 | let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect(); | |
| 1391 | if !unexpected.is_empty() { | |
| 1392 | let names = serde_json::to_string(&unexpected).unwrap_or_default(); | |
| 1393 | let declared = if trigger.inputs.is_empty() { | |
| 1394 | "The workflow declares no inputs.".to_owned() | |
| 1395 | } else { | |
| 1396 | format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", ")) | |
| 1397 | }; | |
| 1398 | return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}")); | |
| 1399 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1400 | let mut inputs = Map::new(); |
| 1401 | for (name, spec) in &trigger.inputs { | |
| 1402 | let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string"); | |
| 1403 | let value = given.get(name).cloned().or_else(|| spec.get("default").cloned()); | |
| 1404 | let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false); | |
| 1405 | let value = match value { | |
| 1406 | Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")), | |
| 1407 | Some(Value::Null) | None => match kind { | |
| 1408 | "boolean" => Value::Bool(false), | |
| 1409 | _ => Value::String(String::new()), | |
| 1410 | }, | |
| 1411 | Some(value) => match kind { | |
| 1412 | "boolean" => Value::Bool(match &value { | |
| 1413 | Value::Bool(flag) => *flag, | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1414 | Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true, |
| 1415 | Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false, | |
| 1416 | _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")), | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1417 | }), |
| 1418 | "number" => match &value { | |
| 1419 | Value::Number(_) => value, | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1420 | Value::String(text) => match number(text) { |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1421 | Some(number) => Value::Number(number), |
| 1422 | None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")), | |
| 1423 | }, | |
| 1424 | _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")), | |
| 1425 | }, | |
| 1426 | "choice" => { | |
| 1427 | let text = g1t_actions::expr::to_text(&value); | |
| 1428 | let options: Vec<String> = | |
| 1429 | spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default(); | |
| 1430 | if !options.is_empty() && !options.contains(&text) { | |
| 1431 | return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", "))); | |
| 1432 | } | |
| 1433 | Value::String(text) | |
| 1434 | } | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1435 | "environment" => { |
| 1436 | let text = g1t_actions::expr::to_text(&value); | |
| 1437 | let wanted = text.trim().to_ascii_lowercase(); | |
| 1438 | if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) { | |
| 1439 | return fail( | |
| 1440 | FailureCode::Invalid, | |
| 1441 | format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()), | |
| 1442 | ); | |
| 1443 | } | |
| 1444 | Value::String(text) | |
| 1445 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1446 | _ => Value::String(g1t_actions::expr::to_text(&value)), |
| 1447 | }, | |
| 1448 | }; | |
| 1449 | inputs.insert(name.clone(), value); | |
| 1450 | } | |
| 1451 | Outcome::Ok(inputs) | |
| 1452 | } | |
| 1453 | ||
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1454 | /// A number input given as text: a whole number stays one. |
| 1455 | fn number(text: &str) -> Option<serde_json::Number> { | |
| 1456 | let text = text.trim(); | |
| 1457 | match text.parse::<i64>() { | |
| 1458 | Ok(whole) => Some(whole.into()), | |
| 1459 | Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64), | |
| 1460 | } | |
| 1461 | } | |
| 1462 | ||
| 1463 | /// A manual run's inputs as `github.event.inputs` gives them: every one as | |
| 1464 | /// text, as GitHub's `workflow_dispatch` payload has them. | |
| 1465 | fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> { | |
| 1466 | inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect() | |
| 1467 | } | |
| 1468 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1469 | #[cfg(test)] |
| 1470 | mod tests { | |
| 1471 | use super::*; | |
| 1472 | use g1t_contracts::work::{Pull, g1t_author}; | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 1473 | |
| 1474 | #[test] | |
| 1475 | fn every_event_a_workflow_runs_on_is_sent_to_this_queue() { | |
| 1476 | for kind in g1t_contracts::webhooks::EVENT_TYPES { | |
| 1477 | if !github_events(kind).is_empty() { | |
| 1478 | assert!( | |
| 1479 | g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind), | |
| 1480 | "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)" | |
| 1481 | ); | |
| 1482 | } | |
| 1483 | } | |
| 1484 | } | |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1485 | use g1t_contracts::{Membership, PrincipalKind}; |
| 1486 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1487 | #[test] |
| 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. | 1488 | fn a_pushers_email_is_their_noreply_address() { |
| 1489 | assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh")); | |
| 1490 | assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL)); | |
| 1491 | assert_eq!(pusher_email(Some("wsp_1"), "acme"), None); | |
| 1492 | assert_eq!(pusher_email(None, "acme"), None); | |
| 1493 | } | |
| 1494 | ||
| 1495 | #[test] | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1496 | fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() { |
| 1497 | let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() }; | |
| 1498 | let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] }; | |
| 1499 | let unavailable = PullMergeRef::Unavailable { reason: "too large".into() }; | |
| 1500 | assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into())); | |
| 1501 | assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into())); | |
| 1502 | // GitHub runs no pull_request workflow while it conflicts. | |
| 1503 | for action in ["opened", "synchronize", "reopened", "edited"] { | |
| 1504 | assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing); | |
| 1505 | } | |
| 1506 | // Closing it, or reviewing it, still runs, on the head. | |
| 1507 | assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head); | |
| 1508 | assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head); | |
| 1509 | // What g1t could not merge itself runs on the head. | |
| 1510 | assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head); | |
| 1511 | } | |
| 1512 | ||
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1513 | fn repo() -> Repo { |
| 1514 | serde_json::from_value(json!({ | |
| 1515 | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true, | |
| 1516 | "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" | |
| 1517 | })) | |
| 1518 | .unwrap() | |
| 1519 | } | |
| 1520 | ||
| 1521 | fn person(id: &str, username: &str) -> User { | |
| 1522 | User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() } | |
| 1523 | } | |
| 1524 | ||
| 1525 | fn made_for(asker: User) -> Pull { | |
| 1526 | serde_json::from_value(json!({ | |
| 1527 | "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null, | |
| 1528 | "agent": "g1t", "runtime": "hosted", "status": "open", | |
| 1529 | "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f", | |
| 1530 | "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null, | |
| 1531 | "supersededBy": null, "checkStatus": null, | |
| 1532 | "author": g1t_author(), "requestedBy": asker, | |
| 1533 | "createdAt": "", "updatedAt": "" | |
| 1534 | })) | |
| 1535 | .unwrap() | |
| 1536 | } | |
| 1537 | ||
| 1538 | #[test] | |
| 1539 | fn g1t_s_change_for_someone_is_trusted_as_they_are() { | |
| 1540 | // Stored people carry no memberships, so identity is asked about | |
| 1541 | // them; being g1t's change gives it nothing more. | |
| 1542 | let pull = made_for(person("usr_2", "ana")); | |
| 1543 | assert!(!trusted_outright(pull.owner(), &repo())); | |
| 1544 | // Someone known to be able to push is trusted at once. | |
| 1545 | let mut member = person("usr_1", "syntaqx"); | |
| 1546 | member.workspaces.push(Membership::member("acme")); | |
| 1547 | let pull = made_for(member); | |
| 1548 | assert!(trusted_outright(pull.owner(), &repo())); | |
| 1549 | } | |
| 1550 | ||
| 1551 | #[test] | |
| 1552 | fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() { | |
| 1553 | let pull = made_for(person("usr_1", "syntaqx")); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1554 | let event = payload::pull(&repo(), &pull); |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1555 | assert_eq!(event["user"]["login"], "g1t"); |
| 1556 | assert_eq!(event["user"]["type"], "Bot"); | |
| 1557 | assert_eq!(event["requested_by"]["login"], "syntaqx"); | |
| 1558 | assert_eq!(event["requested_by"]["type"], "User"); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1559 | let as_issue = payload::pull_as_issue(&repo(), &pull); |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1560 | assert_eq!(as_issue["user"]["login"], "g1t"); |
| 1561 | assert_eq!(as_issue["requested_by"]["login"], "syntaqx"); | |
| 1562 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1563 | |
| 1564 | #[test] | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1565 | fn releases_deployments_and_deleted_comments_read_as_githubs() { |
| 1566 | let release = payload::release( | |
| 1567 | &repo(), | |
| 1568 | &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false, | |
| 1569 | "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }), | |
| 1570 | ); | |
| 1571 | assert_eq!(release["tag_name"], "v1.2.0"); | |
| 1572 | assert_eq!(release["name"], "v1.2.0"); | |
| 1573 | assert_eq!(release["prerelease"], true); | |
| 1574 | assert_eq!(release["author"]["login"], "ana"); | |
| 1575 | assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0"); | |
| 1576 | let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana", | |
| 1577 | "production_environment": false, "created_at": "t", "updated_at": "t" }); | |
| 1578 | let status = payload::deployment_status(&repo(), &json!({ "id": "dst_1", "state": "success", "environment_url": "https://s.example", "log_url": null, "creator": "g1t", "created_at": "t" }), &deployment); | |
| 1579 | assert_eq!(status["state"], "success"); | |
| 1580 | assert_eq!(status["environment"], "staging"); | |
| 1581 | assert_eq!(status["environment_url"], "https://s.example"); | |
| 1582 | assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({})); | |
| 1583 | let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true); | |
| 1584 | assert_eq!(gone["user"]["login"], "bo"); | |
| 1585 | assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1"); | |
| 1586 | assert!(is_commit("0123456789abcdef0123456789abcdef01234567")); | |
| 1587 | assert!(!is_commit("main")); | |
| 1588 | } | |
| 1589 | ||
| 1590 | #[test] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1591 | fn a_pull_request_names_its_own_base_labels_and_milestone() { |
| 1592 | let mut pull = made_for(person("usr_1", "syntaqx")); | |
| 1593 | let event = payload::pull(&repo(), &pull); | |
| 1594 | assert_eq!(event["base"]["ref"], repo().default_branch); | |
| 1595 | pull.base = Some("release/1.x".into()); | |
| 1596 | pull.labels = vec!["bug".into()]; | |
| 1597 | pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() }); | |
| 1598 | let event = payload::pull(&repo(), &pull); | |
| 1599 | assert_eq!(event["base"]["ref"], "release/1.x"); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1600 | assert_eq!(event["labels"][0]["name"], "bug"); |
| 1601 | assert!(event["labels"][0]["id"].is_u64()); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1602 | assert_eq!(event["milestone"]["title"], "1.1"); |
| 1603 | let mut labeled = serde_json::json!({ "action": "labeled" }); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1604 | payload::changed(&repo(), &mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } })); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1605 | assert_eq!(labeled["label"]["color"], "d73a4a"); |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1606 | let mut assigned = serde_json::json!({ "action": "assigned" }); |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 1607 | payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] })); |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 1608 | assert_eq!(assigned["assignee"]["login"], "bo"); |
| 1609 | assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] }))); | |
| 1610 | assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] }))); | |
| 1611 | } | |
| 1612 | ||
| 1613 | fn dispatchable(yaml: &str) -> Trigger { | |
| 1614 | workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone() | |
| 1615 | } | |
| 1616 | ||
| 1617 | fn given(value: Value) -> Map<String, Value> { | |
| 1618 | value.as_object().unwrap().clone() | |
| 1619 | } | |
| 1620 | ||
| 1621 | const DISPATCH: &str = "on: | |
| 1622 | workflow_dispatch: | |
| 1623 | inputs: | |
| 1624 | dry-run: { type: boolean, default: false } | |
| 1625 | count: { type: number, default: 2 } | |
| 1626 | target: { type: environment } | |
| 1627 | level: { type: choice, options: [low, high], default: low } | |
| 1628 | note: { required: false } | |
| 1629 | jobs: | |
| 1630 | a: | |
| 1631 | runs-on: x | |
| 1632 | steps: [{ run: 'true' }]"; | |
| 1633 | ||
| 1634 | #[test] | |
| 1635 | fn an_input_the_workflow_does_not_declare_is_refused() { | |
| 1636 | let trigger = dispatchable(DISPATCH); | |
| 1637 | let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else { | |
| 1638 | panic!("an undeclared input was taken"); | |
| 1639 | }; | |
| 1640 | assert_eq!(failure.code, FailureCode::Invalid); | |
| 1641 | assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message); | |
| 1642 | assert!(failure.message.contains("`dry-run`")); | |
| 1643 | let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]"); | |
| 1644 | let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() }; | |
| 1645 | assert!(failure.message.contains("declares no inputs")); | |
| 1646 | assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty())); | |
| 1647 | } | |
| 1648 | ||
| 1649 | #[test] | |
| 1650 | fn inputs_are_typed_and_the_event_has_them_as_text() { | |
| 1651 | let trigger = dispatchable(DISPATCH); | |
| 1652 | let environments = vec!["staging".to_owned(), "production".to_owned()]; | |
| 1653 | let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments) | |
| 1654 | else { | |
| 1655 | panic!("refused"); | |
| 1656 | }; | |
| 1657 | assert_eq!(inputs["dry-run"], json!(true)); | |
| 1658 | assert_eq!(inputs["count"], json!(3)); | |
| 1659 | assert_eq!(inputs["level"], json!("low")); | |
| 1660 | assert_eq!(inputs["note"], json!("")); | |
| 1661 | let event = event_inputs(&inputs); | |
| 1662 | assert_eq!(event["dry-run"], json!("true")); | |
| 1663 | assert_eq!(event["count"], json!("3")); | |
| 1664 | assert_eq!(event["target"], json!("Staging")); | |
| 1665 | let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() }; | |
| 1666 | assert_eq!(event_inputs(&defaults)["dry-run"], json!("false")); | |
| 1667 | assert_eq!(defaults["count"], json!(2)); | |
| 1668 | let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) { | |
| 1669 | Outcome::Fail(failure) => failure.message, | |
| 1670 | Outcome::Ok(_) => panic!("taken"), | |
| 1671 | }; | |
| 1672 | assert!(refused(json!({ "target": "qa" })).contains("none called `qa`")); | |
| 1673 | assert!(refused(json!({ "dry-run": "yes" })).contains("true or false")); | |
| 1674 | assert!(refused(json!({ "count": "many" })).contains("is a number")); | |
| 1675 | assert!(refused(json!({ "level": "mid" })).contains("low, high")); | |
| 1676 | } | |
| 1677 | ||
| 1678 | #[test] | |
| 1679 | fn create_and_delete_take_no_filters_and_name_their_ref() { | |
| 1680 | assert!(unfiltered("create") && unfiltered("delete")); | |
| 1681 | assert!(!unfiltered("push") && !unfiltered("pull_request")); | |
| 1682 | assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x"))); | |
| 1683 | assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0"))); | |
| 1684 | assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None); | |
| 1685 | assert_eq!(branch_or_tag("refs/notes/commits"), None); | |
| 1686 | // A workflow can name `delete`. It takes no filters, as GitHub's | |
| 1687 | // schema says, so writing one is refused when the workflow is read. | |
| 1688 | assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err()); | |
| 1689 | let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); | |
| 1690 | let trigger = workflow.trigger("delete").unwrap(); | |
| 1691 | assert!(trigger.wants_type(None)); | |
| 1692 | assert!(!workflow.notes.iter().any(|note| note.severity == workflow::Severity::Unsupported)); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1693 | } |
| Merge branch 'worktree-agent-a6a121745e81f639f' | 1694 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.