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