flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/actions/src/trigger.rs

876 lines39,522 bytesCodeBlame

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.

GitHub Actions on g1t, part two: running workflows1//! 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
5use g1t_actions::events::{RunInfo, github_events};
6use g1t_actions::workflow::{self, Trigger, Workflow};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7use g1t_contracts::access::{self, Capability};
GitHub Actions on g1t, part two: running workflows8use g1t_contracts::actions::{DispatchArgs, WorkflowRun};
9use g1t_contracts::events::Event;
Free while g1t is being built out; agents can check out their own forks10use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
GitHub Actions on g1t, part two: running workflows11use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath};
12use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
Free while g1t is being built out; agents can check out their own forks13use g1t_contracts::{FailureCode, Outcome, User, new_id};
GitHub Actions on g1t, part two: running workflows14use g1t_kit::now_ms;
15use serde_json::{Map, Value, json};
16use worker::Result;
17
18use crate::plan::NewRun;
19use crate::sync::{Read, WorkflowRow};
20use crate::{API, Actions, SITE, check, fail, payload};
21
22/// What an event is about, worked out once for every workflow it starts.
23struct Subject {
24 /// Where the workflow files are read, and at which commit.
25 source: RepoPath,
26 source_ref: Option<String>,
27 git_ref: String,
28 sha: String,
29 head_ref: Option<String>,
30 base_ref: Option<String>,
31 pull: Option<u32>,
32 /// The branch or tag for `branches`/`tags` filters; for pull requests,
33 /// the branch they merge into.
34 filter_ref: String,
35 /// The files it changes, for `paths` filters; `None` until needed.
36 paths: Option<Vec<String>>,
37 /// For a push, what to compare to find the files.
38 compare: Option<(Option<String>, String)>,
39 payload: Value,
40 title: String,
41 trusted: bool,
42}
43
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights44/// Whether whoever a pull request is for is trusted without asking
45/// identity: g1t's agent in work nobody asked it for, or someone whose
46/// role here is known to allow pushing.
47fn trusted_outright(owner: &User, repo: &Repo) -> bool {
48 owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push)
49}
50
GitHub Actions on g1t, part two: running workflows51impl Actions {
52 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
53 let Some(id) = id else { return Ok(None) };
54 if id == AGENT_ID {
55 return Ok(Some(AGENT_NAME.to_owned()));
56 }
57 let names: std::collections::HashMap<String, String> =
58 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
59 Ok(names.get(id).cloned())
60 }
61
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights62 /// Whether whoever a pull request is for (Pull::owner: whoever asked
63 /// g1t for it, or its author) could push to the repository, so its
64 /// runs get the secrets and a token. Anyone else's, a reader's included
65 /// (who may open one on a private repository too), runs without them.
66 /// A change g1t made for someone is trusted as they are.
67 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
68 if trusted_outright(owner, repo) {
GitHub Actions on g1t, part two: running workflows69 return Ok(true);
70 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look71 // Stored authors carry no memberships or grants: ask identity, as
72 // the workspace (which may see anyone's permission).
73 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
Free while g1t is being built out; agents can check out their own forks74 &self.identity,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 "collaborator_permission",
76 &access::CollaboratorPermissionArgs {
77 viewer: Some(ws.clone()),
78 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 rights79 username: owner.username.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look80 },
Free while g1t is being built out; agents can check out their own forks81 )
82 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look83 Ok(permission
84 .into_result()
85 .ok()
86 .and_then(|info| info.role)
87 .is_some_and(|role| access::allows(role, Capability::Push)))
GitHub Actions on g1t, part two: running workflows88 }
89
90 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
91 let log: Outcome<Vec<Commit>> = g1t_kit::call(
92 &self.repos,
93 "log",
94 &LogArgs {
95 path: RepoPath {
96 namespace: repo.namespace.clone(),
97 name: repo.name.clone(),
98 },
99 viewer: Some(actor.clone()),
100 git_ref: Some(after.to_owned()),
101 limit: 20,
102 },
103 )
104 .await?;
105 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
106 if let Some(before) = before
107 && let Some(at) = commits.iter().position(|commit| commit.hash == before)
108 {
109 commits.truncate(at);
110 }
111 // GitHub lists them oldest first, with the head commit last.
112 commits.reverse();
113 Ok(commits)
114 }
115
116 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
117 let compared: Outcome<Comparison> = g1t_kit::call(
118 &self.repos,
119 "compare",
120 &CompareArgs {
121 repo_id: repo.id.clone(),
122 viewer: Some(actor.clone()),
123 base,
124 head: Some(head),
125 },
126 )
127 .await?;
128 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default())
129 }
130
131 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
132 g1t_kit::call(
133 &self.repos,
134 "head",
135 &g1t_contracts::repos::HeadArgs {
136 repo_id: repo.id.clone(),
137 branch: repo.default_branch.clone(),
138 },
139 )
140 .await
141 }
142
143 fn repo_path(repo: &Repo) -> RepoPath {
144 RepoPath {
145 namespace: repo.namespace.clone(),
146 name: repo.name.clone(),
147 }
148 }
149
150 /// The subject of an event of `kind`, as GitHub's `event_name`.
151 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
152 let path = Self::repo_path(repo);
153 let data = &event.data;
154 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
155 source: path.clone(),
156 source_ref: None,
157 git_ref: format!("refs/heads/{}", repo.default_branch),
158 sha,
159 head_ref: None,
160 base_ref: None,
161 pull,
162 filter_ref: format!("refs/heads/{}", repo.default_branch),
163 paths: None,
164 compare: None,
165 payload,
166 title,
167 trusted: true,
168 };
169 let view = |number: u32| ViewArgs {
170 repo: path.clone(),
171 number,
172 viewer: Some(ws.clone()),
173 after_seq: 0,
174 };
175 Ok(match event_name {
176 "push" => {
177 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
178 return Ok(None);
179 };
Sidebar: the panels really slide180 // The merge queue's states run merge_group workflows, not push ones.
181 if git_ref.starts_with("refs/heads/g1t-queue/") {
182 return Ok(None);
183 }
GitHub Actions on g1t, part two: running workflows184 let before = data["before"].as_str();
185 let commits = self.commits(repo, ws, after, before).await?;
186 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
187 let mut payload = payload::push(repo, git_ref, before, after, &commits, sender);
188 if let Some(head) = commits.last() {
189 payload["head_commit"] = payload::commit(repo, head);
190 }
191 Some(Subject {
192 source: path.clone(),
193 source_ref: Some(after.to_owned()),
194 git_ref: git_ref.to_owned(),
195 sha: after.to_owned(),
196 head_ref: None,
197 base_ref: None,
198 pull: None,
199 filter_ref: git_ref.to_owned(),
200 paths: None,
201 compare: Some((before.map(str::to_owned), after.to_owned())),
202 payload,
203 title,
204 trusted: true,
205 })
206 }
207 "pull_request" | "pull_request_target" | "pull_request_review" => {
208 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
209 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
210 let Outcome::Ok(detail) = detail else { return Ok(None) };
211 let pull = &detail.pull;
212 let labels = detail.issue.as_ref().map(|i| i.labels.clone()).unwrap_or_default();
213 let mut payload = json!({
214 "action": action,
215 "number": pull.number,
216 "pull_request": payload::pull(repo, pull, &labels),
217 "repository": payload::repository(repo),
218 "sender": payload::user(sender),
219 });
220 if event_name == "pull_request_review" {
221 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some());
222 payload["review"] = json!({
223 "state": review.and_then(|r| r.verdict).map(|v| format!("{v:?}").to_lowercase()),
224 "body": review.map(|r| r.body.clone()),
225 "user": review.map(|r| payload::user(&r.author.username)),
226 });
227 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights228 let trusted = self.insider(pull.owner(), repo, ws).await?;
GitHub Actions on g1t, part two: running workflows229 let head_ref = payload::head_ref(pull);
230 if event_name == "pull_request_target" {
231 // In the base's context: its workflows, its head.
232 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
233 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
234 subject.head_ref = Some(head_ref);
235 subject.base_ref = Some(repo.default_branch.clone());
236 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
237 return Ok(Some(subject));
238 }
A repository has its own sidebar, as settings do239 // A merged pull request's run is on the commit it landed as,
240 // in the repository; otherwise on its head, where that is.
241 let landed = match (action, data["commit"].as_str()) {
242 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
243 _ => None,
244 };
245 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
246 (Some(commit), _, _) => commit.clone(),
247 (None, Some(commit), _) => commit.to_owned(),
248 (None, None, Some(head)) => head.clone(),
249 (None, None, None) => return Ok(None),
250 };
251 let source = match landed {
252 Some(_) => path.clone(),
253 None => pull.fork.clone().unwrap_or_else(|| path.clone()),
GitHub Actions on g1t, part two: running workflows254 };
255 Some(Subject {
A repository has its own sidebar, as settings do256 source,
GitHub Actions on g1t, part two: running workflows257 source_ref: Some(sha.clone()),
258 git_ref: format!("refs/pull/{}/merge", pull.number),
259 sha,
260 head_ref: Some(head_ref),
261 base_ref: Some(repo.default_branch.clone()),
262 pull: Some(pull.number),
263 filter_ref: format!("refs/heads/{}", repo.default_branch),
264 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
265 compare: None,
266 payload,
267 title: pull.title.clone(),
268 trusted,
269 })
270 }
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24271 "workflow_run" => {
272 // A run of a workflow_run workflow does not start another,
273 // so two such workflows cannot set each other off.
274 if data["event"].as_str() == Some("workflow_run") {
275 return Ok(None);
276 }
277 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
278 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
279 let name = data["workflow"].as_str().unwrap_or_default();
280 let payload = json!({
281 "action": "completed",
282 "workflow_run": {
283 "id": data["runId"],
284 "name": name,
285 "path": data["path"],
286 "event": data["event"],
287 "status": "completed",
288 "conclusion": data["conclusion"],
289 "head_sha": data["sha"],
290 "head_branch": head_branch,
291 "run_number": data["number"],
292 "html_url": format!("{SITE}/{}/{}/actions/runs/{}", repo.namespace, repo.name, data["runId"].as_str().unwrap_or_default()),
293 "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ "number": n })]).unwrap_or_default(),
294 },
295 "workflow": { "name": name, "path": data["path"] },
296 "repository": payload::repository(repo),
297 "sender": payload::user(sender),
298 });
299 let mut subject = on_default(sha, payload, format!("After {name}"), None);
300 // Branch filters apply to the branch the followed run was on.
301 subject.filter_ref = format!("refs/heads/{head_branch}");
302 Some(subject)
303 }
GitHub Actions on g1t, part two: running workflows304 "issues" | "issue_comment" => {
305 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
306 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
307 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
308 let (issue_json, comments, title, on_pull) = match issue {
309 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
310 Outcome::Fail(_) => {
311 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
312 let Outcome::Ok(detail) = pull else { return Ok(None) };
313 let labels = detail.issue.as_ref().map(|i| i.labels.clone()).unwrap_or_default();
314 (payload::pull_as_issue(repo, &detail.pull, &labels), detail.comments, detail.pull.title.clone(), true)
315 }
316 };
317 let mut payload = json!({
318 "action": action,
319 "issue": issue_json,
320 "repository": payload::repository(repo),
321 "sender": payload::user(sender),
322 });
323 if event_name == "issue_comment" {
324 let comment_id = data["commentId"].as_str();
325 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id).or(comments.last());
326 match comment {
327 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
328 None => return Ok(None),
329 }
330 }
331 Some(on_default(sha, payload, title, on_pull.then_some(number)))
332 }
333 _ => None,
334 })
335 }
336
337 pub async fn on_event(&self, event: &Event) -> Result<()> {
338 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
339 let mapped = github_events(&event.kind);
340 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
341 if mapped.is_empty() && !pushed_default {
342 return Ok(());
343 }
344 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
345 if pushed_default {
346 self.sync(&repo, &ws).await?;
347 }
348 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
349 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 for350 // Issues and comments start the default branch's workflows,
351 // which the synced table lists: when none listens, nothing is
352 // read from git. Agents make many of these events.
353 if matches!(event_name, "issues" | "issue_comment") && self.listens(repo_id, event_name).await? == Some(false) {
354 continue;
355 }
GitHub Actions on g1t, part two: running workflows356 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
357 continue;
358 };
359 let read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?;
A repository has its own sidebar, as settings do360 // A pull request's head runs each workflow once, however many
361 // events say it is there (marked ready, and pushed).
362 let key = match subject.pull {
363 Some(number) if event_name.starts_with("pull_request") && event_name != "pull_request_review" => {
364 let phase = if action == Some("closed") { "closed" } else { "open" };
365 format!("{event_name}:{number}:{}:{phase}", subject.sha)
366 }
367 _ => event.id.clone(),
368 };
369 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
GitHub Actions on g1t, part two: running workflows370 .await?;
371 }
372 Ok(())
373 }
374
375 #[allow(clippy::too_many_arguments)]
376 async fn start_matching(
377 &self,
378 repo: &Repo,
379 ws: &User,
380 read: Read,
381 subject: &mut Subject,
382 event_name: &str,
383 action: Option<&str>,
384 event_key: &str,
385 actor_id: Option<&str>,
386 sender: &str,
387 ) -> Result<()> {
388 for file in read.files {
389 let parsed = workflow::parse(&file.source);
390 let workflow = match parsed {
391 Ok(workflow) => workflow,
392 Err(problem) => {
393 // A push shows a broken workflow as a failed run, as GitHub does.
394 if event_name == "push" && file.source.contains("on") {
395 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
396 .await?;
397 }
398 continue;
399 }
400 };
401 let Some(trigger) = workflow.trigger(event_name) else { continue };
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24402 // workflow_run follows the workflows it names.
403 if event_name == "workflow_run" {
404 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
405 if !trigger.workflows.iter().any(|name| name == followed) {
406 continue;
407 }
408 }
GitHub Actions on g1t, part two: running workflows409 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
410 continue;
411 }
412 if self.disabled(&repo.id, &file.path).await? {
413 continue;
414 }
415 self.create_run(NewRun {
416 repo: repo.clone(),
417 path: file.path,
418 source: file.source,
419 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
420 workflow,
421 action: action.map(str::to_owned),
422 pull: subject.pull,
423 title: subject.title.clone(),
424 inputs: Map::new(),
425 event_key: event_key.to_owned(),
426 actor_id: actor_id.map(str::to_owned),
427 actor: Some(sender.to_owned()),
428 trusted: subject.trusted,
429 })
430 .await?;
431 }
432 Ok(())
433 }
434
435 /// Whether the branch, tag and path filters let the event through.
436 async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> {
437 let git_ref = subject.filter_ref.as_str();
438 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
439 // A tag push runs a workflow that filters tags, or filters nothing.
440 if trigger.tags.is_set() {
441 if !trigger.tags.allows(tag) {
442 return Ok(false);
443 }
444 } else if trigger.branches.is_set() {
445 return Ok(false);
446 }
447 // Paths are not checked for tags, as on GitHub.
448 return Ok(true);
449 }
450 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
451 if trigger.branches.is_set() {
452 if !trigger.branches.allows(branch) {
453 return Ok(false);
454 }
455 } else if event_name == "push" && trigger.tags.is_set() {
456 return Ok(false);
457 }
458 if trigger.paths.is_set() {
459 if subject.paths.is_none() {
460 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
461 subject.paths = Some(self.changed_paths(repo, ws, base, head).await?);
462 }
463 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
464 return Ok(false);
465 }
466 }
467 Ok(true)
468 }
469
470 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
471 let row = self
472 .db
473 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
474 .bind(&[repo_id.into(), path.into()])?
475 .first::<WorkflowRow>(None)
476 .await?;
477 Ok(row.is_some_and(|row| row.state == "disabled"))
478 }
479
480 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
481 RunInfo {
482 repository: format!("{}/{}", repo.namespace, repo.name),
483 repository_id: repo.id.clone(),
484 default_branch: repo.default_branch.clone(),
485 event_name: event_name.to_owned(),
486 event: subject.payload.clone(),
487 git_ref: subject.git_ref.clone(),
488 sha: subject.sha.clone(),
489 head_ref: subject.head_ref.clone(),
490 base_ref: subject.base_ref.clone(),
491 actor: sender.to_owned(),
492 actor_id: actor_id.unwrap_or_default().to_owned(),
493 triggering_actor: sender.to_owned(),
494 run_id: String::new(),
495 run_number: 0,
496 run_attempt: 1,
497 workflow: workflow.name.clone().unwrap_or_default(),
498 workflow_path: String::new(),
499 server_url: SITE.to_owned(),
500 api_url: API.to_owned(),
501 }
502 }
503
504 #[allow(clippy::too_many_arguments)]
505 async fn record_invalid(
506 &self,
507 repo: &Repo,
508 path: &str,
509 source: &str,
510 subject: &Subject,
511 event_key: &str,
512 actor_id: Option<&str>,
513 sender: &str,
514 problem: &str,
515 ) -> Result<()> {
516 let row = self.workflow_row(repo, path, path, source).await?;
517 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
518 }
519
520 /// Scheduled workflows whose cron fires this minute, on the default branch.
521 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
522 let rows = self
523 .db
524 .prepare("SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL")
525 .all()
526 .await?
527 .results::<WorkflowRow>()?;
528 for row in rows {
529 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
530 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.fires_at(minute))) else {
531 continue;
532 };
533 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look534 // Schedules wait while a repository is archived; a deleted one is not found.
535 let Some((repo, _ws)) = self.repo_by_id(&row.repo_id).await?.filter(|(repo, _)| !repo.archived()) else { continue };
GitHub Actions on g1t, part two: running workflows536 let Some(sha) = self.default_head(&repo).await? else { continue };
537 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
538 let mut subject = Subject {
539 source: Self::repo_path(&repo),
540 source_ref: None,
541 git_ref: format!("refs/heads/{}", repo.default_branch),
542 sha,
543 head_ref: None,
544 base_ref: None,
545 pull: None,
546 filter_ref: String::new(),
547 paths: None,
548 compare: None,
549 payload,
550 title: format!("Scheduled: {cron}"),
551 trusted: true,
552 };
553 subject.filter_ref = subject.git_ref.clone();
554 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
555 self.create_run(NewRun {
556 repo: repo.clone(),
557 path: row.path.clone(),
558 source: row.source.clone(),
559 workflow,
560 info,
561 action: None,
562 pull: None,
563 title: subject.title.clone(),
564 inputs: Map::new(),
565 event_key: format!("schedule:{minute}"),
566 actor_id: None,
567 actor: None,
568 trusted: true,
569 })
570 .await?;
571 }
572 Ok(())
573 }
574
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look575 /// `dispatch`: someone with the Write role runs a workflow that has
576 /// `workflow_dispatch`.
GitHub Actions on g1t, part two: running workflows577 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 look578 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
579 if repo.archived() {
580 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
GitHub Actions on g1t, part two: running workflows581 }
582 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
583 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
584 };
585 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
586 let full_ref = if git_ref.starts_with("refs/") {
587 git_ref.clone()
588 } else {
589 // A branch if there is one by that name, otherwise a tag.
590 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
591 &self.repos,
592 "branches",
593 &g1t_contracts::repos::BranchesArgs {
594 path: Self::repo_path(&repo),
595 viewer: Some(ws.clone()),
596 },
597 )
598 .await?;
599 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
600 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
601 };
602 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
603 let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&short)).await?;
604 let Some(sha) = read.head.clone() else {
605 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
606 };
Search across all of g1t, Explore, and a command palette607 // A workflow is named by its file (`build.yml`), its path, or its id
608 // (`wfl_…`), which stands for the path it was read from.
609 let by_id = if a.workflow.starts_with("wfl_") {
610 self.db
611 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
612 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
613 .first::<WorkflowRow>(None)
614 .await?
615 .map(|row| row.path)
616 } else {
617 None
618 };
619 let named = by_id.as_deref().unwrap_or(&a.workflow);
620 let wanted = named.trim_start_matches(".g1t/workflows/");
GitHub Actions on g1t, part two: running workflows621 let Some(file) = read.files.iter().find(|file| {
Search across all of g1t, Explore, and a command palette622 file.path.rsplit('/').next() == Some(wanted) || file.path == named
GitHub Actions on g1t, part two: running workflows623 }) else {
624 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
625 };
626 let workflow = match workflow::parse(&file.source) {
627 Ok(workflow) => workflow,
628 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
629 };
630 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
631 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
632 };
633 let inputs = check!(dispatch_inputs(trigger, &a.inputs));
634 let payload = json!({
635 "inputs": inputs,
636 "ref": full_ref,
637 "repository": payload::repository(&repo),
638 "sender": payload::user(&a.actor.username),
639 "workflow": file.path,
640 });
641 let subject = Subject {
642 source: Self::repo_path(&repo),
643 source_ref: Some(sha.clone()),
644 git_ref: full_ref.clone(),
645 sha,
646 head_ref: None,
647 base_ref: None,
648 pull: None,
649 filter_ref: full_ref,
650 paths: None,
651 compare: None,
652 payload,
653 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
654 trusted: true,
655 };
656 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
657 let created = self
658 .create_run(NewRun {
659 repo: repo.clone(),
660 path: file.path.clone(),
661 source: file.source.clone(),
662 workflow,
663 info,
664 action: None,
665 pull: None,
666 title: subject.title.clone(),
667 inputs,
668 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
669 actor_id: Some(a.actor.id.clone()),
670 actor: Some(a.actor.username.clone()),
671 trusted: true,
672 })
673 .await?;
674 match created {
675 Some(id) => self.run_summary(&id).await,
676 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
677 }
678 }
679}
680
Sidebar: the panels really slide681#[derive(serde::Deserialize)]
682#[serde(rename_all = "camelCase")]
683pub struct MergeGroupArgs {
684 pub repo_id: String,
685 pub entry: String,
686 pub sha: String,
687 pub head_ref: String,
688 #[serde(default)]
689 pub base_sha: Option<String>,
690 pub number: u32,
691 #[serde(default)]
692 pub ahead: Vec<u32>,
693}
694
695impl Actions {
696 /// `merge_group`: the merge queue built a state and its checks passed.
697 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
698 /// queue does, and says how many started; the queue waits for their
699 /// statuses on that commit.
700 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
701 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
702 return Ok(Outcome::Ok(json!({ "runs": 0 })));
703 };
704 let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
705 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
706 let payload = json!({
707 "action": "checks_requested",
708 "merge_group": {
709 "head_sha": a.sha,
710 "head_ref": a.head_ref,
711 "base_sha": a.base_sha,
712 "base_ref": format!("refs/heads/{}", repo.default_branch),
713 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
714 },
715 "repository": payload::repository(&repo),
716 "sender": payload::user(&repo.namespace),
717 });
718 let mut started = 0u32;
719 for file in read.files {
720 let Ok(workflow) = workflow::parse(&file.source) else { continue };
721 let Some(trigger) = workflow.trigger("merge_group") else { continue };
722 if !trigger.wants_type(Some("checks_requested")) || self.disabled(&repo.id, &file.path).await? {
723 continue;
724 }
725 // Branch filters on merge_group name the branch it merges into.
726 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
727 continue;
728 }
729 let ahead = if a.ahead.is_empty() {
730 String::new()
731 } else {
732 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
733 };
734 let subject = Subject {
735 source: Self::repo_path(&repo),
736 source_ref: Some(a.sha.clone()),
737 git_ref: a.head_ref.clone(),
738 sha: a.sha.clone(),
739 head_ref: None,
740 base_ref: Some(repo.default_branch.clone()),
741 pull: Some(a.number),
742 filter_ref: format!("refs/heads/{}", repo.default_branch),
743 paths: None,
744 compare: None,
745 payload: payload.clone(),
746 title: format!("Merge queue: #{}{ahead}", a.number),
747 trusted: true,
748 };
749 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
750 let created = self
751 .create_run(NewRun {
752 repo: repo.clone(),
753 path: file.path.clone(),
754 source: file.source.clone(),
755 workflow,
756 info,
757 action: Some("checks_requested".to_owned()),
758 pull: Some(a.number),
759 title: subject.title.clone(),
760 inputs: Map::new(),
761 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
762 actor_id: None,
763 actor: None,
764 trusted: true,
765 })
766 .await?;
767 if created.is_some() {
768 started += 1;
769 }
770 }
771 Ok(Outcome::Ok(json!({ "runs": started })))
GitHub Actions on g1t, part two: running workflows772 }
773}
774
775/// The inputs of a manual run: what was given, checked against the
776/// workflow's declared inputs, with their defaults filled in.
777fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>) -> Outcome<Map<String, Value>> {
778 let mut inputs = Map::new();
779 for (name, spec) in &trigger.inputs {
780 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
781 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
782 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
783 let value = match value {
784 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
785 Some(Value::Null) | None => match kind {
786 "boolean" => Value::Bool(false),
787 _ => Value::String(String::new()),
788 },
789 Some(value) => match kind {
790 "boolean" => Value::Bool(match &value {
791 Value::Bool(flag) => *flag,
792 Value::String(text) => text == "true",
793 _ => false,
794 }),
795 "number" => match &value {
796 Value::Number(_) => value,
797 Value::String(text) => match text.parse::<f64>().ok().and_then(serde_json::Number::from_f64) {
798 Some(number) => Value::Number(number),
799 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
800 },
801 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
802 },
803 "choice" => {
804 let text = g1t_actions::expr::to_text(&value);
805 let options: Vec<String> =
806 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
807 if !options.is_empty() && !options.contains(&text) {
808 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
809 }
810 Value::String(text)
811 }
812 _ => Value::String(g1t_actions::expr::to_text(&value)),
813 },
814 };
815 inputs.insert(name.clone(), value);
816 }
817 Outcome::Ok(inputs)
818}
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use g1t_contracts::work::{Pull, g1t_author};
824 use g1t_contracts::{Membership, PrincipalKind};
825
826 fn repo() -> Repo {
827 serde_json::from_value(json!({
828 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
829 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
830 }))
831 .unwrap()
832 }
833
834 fn person(id: &str, username: &str) -> User {
835 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
836 }
837
838 fn made_for(asker: User) -> Pull {
839 serde_json::from_value(json!({
840 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
841 "agent": "g1t", "runtime": "hosted", "status": "open",
842 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
843 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
844 "supersededBy": null, "checkStatus": null,
845 "author": g1t_author(), "requestedBy": asker,
846 "createdAt": "", "updatedAt": ""
847 }))
848 .unwrap()
849 }
850
851 #[test]
852 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
853 // Stored people carry no memberships, so identity is asked about
854 // them; being g1t's change gives it nothing more.
855 let pull = made_for(person("usr_2", "ana"));
856 assert!(!trusted_outright(pull.owner(), &repo()));
857 // Someone known to be able to push is trusted at once.
858 let mut member = person("usr_1", "syntaqx");
859 member.workspaces.push(Membership::member("acme"));
860 let pull = made_for(member);
861 assert!(trusted_outright(pull.owner(), &repo()));
862 }
863
864 #[test]
865 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
866 let pull = made_for(person("usr_1", "syntaqx"));
867 let event = payload::pull(&repo(), &pull, &[]);
868 assert_eq!(event["user"]["login"], "g1t");
869 assert_eq!(event["user"]["type"], "Bot");
870 assert_eq!(event["requested_by"]["login"], "syntaqx");
871 assert_eq!(event["requested_by"]["type"], "User");
872 let as_issue = payload::pull_as_issue(&repo(), &pull, &[]);
873 assert_eq!(as_issue["user"]["login"], "g1t");
874 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
875 }
876}