| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use forge_ci::config::{CiConfig, Event, parse_config}; |
| 8 | use forge_core::authz::{Action, authorize}; |
| 9 | use forge_core::ci::{CiRun, NewJob}; |
| 10 | use forge_core::store::commit_status::StatusState; |
| 11 | use forge_core::{PullRequest, Repo}; |
| 12 | use forge_git::browse::{read_blob, resolve_commit}; |
| 13 | |
| 14 | use super::{publish, sync_run_statuses}; |
| 15 | use crate::internal::is_zero_oid; |
| 16 | use crate::state::AppState; |
| 17 | |
| 18 | |
| 19 | pub const CONFIG_PATH: &str = ".forge/ci.toml"; |
| 20 | |
| 21 | const MAX_CONFIG_BYTES: usize = 64 * 1024; |
| 22 | |
| 23 | |
| 24 | type PlannedJob = (String, String, Vec<String>); |
| 25 | |
| 26 | |
| 27 | enum Loaded { |
| 28 | Missing, |
| 29 | Invalid(String), |
| 30 | Valid(CiConfig), |
| 31 | } |
| 32 | |
| 33 | |
| 34 | enum Plan { |
| 35 | |
| 36 | Missing, |
| 37 | |
| 38 | NotTriggered, |
| 39 | |
| 40 | Run { |
| 41 | jobs: Vec<PlannedJob>, |
| 42 | config_error: Option<String>, |
| 43 | }, |
| 44 | } |
| 45 | |
| 46 | |
| 47 | struct Request<'a> { |
| 48 | repo: &'a Repo, |
| 49 | trigger: &'static str, |
| 50 | git_ref: &'a str, |
| 51 | commit: &'a str, |
| 52 | pull_number: Option<i64>, |
| 53 | actor_id: &'a str, |
| 54 | needs_approval: bool, |
| 55 | } |
| 56 | |
| 57 | |
| 58 | |
| 59 | |
| 60 | pub async fn on_ref_update( |
| 61 | state: &AppState, |
| 62 | repo: &Repo, |
| 63 | actor_id: &str, |
| 64 | refname: &str, |
| 65 | new_oid: &str, |
| 66 | ) { |
| 67 | if !state.config.ci.enabled || is_zero_oid(new_oid) { |
| 68 | return; |
| 69 | } |
| 70 | let (trigger, event) = if let Some(branch) = refname.strip_prefix("refs/heads/") { |
| 71 | ("push", Event::Push { branch }) |
| 72 | } else if let Some(tag) = refname.strip_prefix("refs/tags/") { |
| 73 | ("tag", Event::Tag { tag }) |
| 74 | } else { |
| 75 | return; |
| 76 | }; |
| 77 | let result = async { |
| 78 | let loaded = load(state, &repo.id, new_oid).await; |
| 79 | if let Plan::Run { jobs, config_error } = plan_for(loaded, &event)? { |
| 80 | create( |
| 81 | state, |
| 82 | Request { |
| 83 | repo, |
| 84 | trigger, |
| 85 | git_ref: refname, |
| 86 | commit: new_oid, |
| 87 | pull_number: None, |
| 88 | actor_id, |
| 89 | needs_approval: false, |
| 90 | }, |
| 91 | jobs, |
| 92 | config_error.as_deref(), |
| 93 | ) |
| 94 | .await?; |
| 95 | } |
| 96 | forge_core::Result::Ok(()) |
| 97 | } |
| 98 | .await; |
| 99 | if let Err(e) = result { |
| 100 | tracing::error!(error = %e, repo_id = %repo.id, refname, "ci: scheduling a push failed"); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | |
| 105 | |
| 106 | |
| 107 | pub async fn on_pull_event(state: &AppState, repo: &Repo, pull: &PullRequest, actor_id: &str) { |
| 108 | if !state.config.ci.enabled { |
| 109 | return; |
| 110 | } |
| 111 | if let Err(e) = pull_run(state, repo, pull, actor_id).await { |
| 112 | tracing::error!(error = %e, repo_id = %repo.id, number = pull.issue.number, "ci: scheduling a pull request failed"); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | async fn pull_run( |
| 117 | state: &AppState, |
| 118 | repo: &Repo, |
| 119 | pull: &PullRequest, |
| 120 | actor_id: &str, |
| 121 | ) -> forge_core::Result<()> { |
| 122 | let loaded = load(state, &repo.id, &pull.head_oid).await; |
| 123 | let Plan::Run { jobs, config_error } = plan_for(loaded, &Event::PullRequest)? else { |
| 124 | return Ok(()); |
| 125 | }; |
| 126 | let needs_approval = needs_approval(state, repo, pull).await?; |
| 127 | let git_ref = format!("refs/pull/{}/head", pull.issue.number); |
| 128 | create( |
| 129 | state, |
| 130 | Request { |
| 131 | repo, |
| 132 | trigger: "pull_request", |
| 133 | git_ref: &git_ref, |
| 134 | commit: &pull.head_oid, |
| 135 | pull_number: Some(pull.issue.number), |
| 136 | actor_id, |
| 137 | needs_approval, |
| 138 | }, |
| 139 | jobs, |
| 140 | config_error.as_deref(), |
| 141 | ) |
| 142 | .await?; |
| 143 | Ok(()) |
| 144 | } |
| 145 | |
| 146 | |
| 147 | |
| 148 | |
| 149 | pub async fn manual_run( |
| 150 | state: &AppState, |
| 151 | repo: &Repo, |
| 152 | branch: &str, |
| 153 | actor_id: &str, |
| 154 | ) -> forge_core::Result<CiRun> { |
| 155 | if !state.config.ci.enabled { |
| 156 | return Err(forge_core::Error::Invalid( |
| 157 | "CI is disabled on this instance".into(), |
| 158 | )); |
| 159 | } |
| 160 | let git_ref = format!("refs/heads/{branch}"); |
| 161 | let repo_dir = forge_git::repo::repo_path(&state.git.data_dir, &repo.id) |
| 162 | .map_err(|e| forge_core::Error::Git(e.to_string()))?; |
| 163 | |
| 164 | let short = branch.to_owned(); |
| 165 | let Some(commit) = blocking(move || resolve_commit(&repo_dir, &short)).await? else { |
| 166 | return Err(forge_core::Error::NotFound); |
| 167 | }; |
| 168 | let loaded = load(state, &repo.id, &commit).await; |
| 169 | match plan_for(loaded, &Event::Manual)? { |
| 170 | Plan::Run { jobs, config_error } => { |
| 171 | create( |
| 172 | state, |
| 173 | Request { |
| 174 | repo, |
| 175 | trigger: "manual", |
| 176 | git_ref: &git_ref, |
| 177 | commit: &commit, |
| 178 | pull_number: None, |
| 179 | actor_id, |
| 180 | needs_approval: false, |
| 181 | }, |
| 182 | jobs, |
| 183 | config_error.as_deref(), |
| 184 | ) |
| 185 | .await |
| 186 | } |
| 187 | Plan::NotTriggered => Err(forge_core::Error::Invalid( |
| 188 | "manual runs are not enabled in .forge/ci.toml".into(), |
| 189 | )), |
| 190 | Plan::Missing => Err(forge_core::Error::Invalid( |
| 191 | "this branch has no .forge/ci.toml".into(), |
| 192 | )), |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | |
| 197 | |
| 198 | async fn create( |
| 199 | state: &AppState, |
| 200 | req: Request<'_>, |
| 201 | jobs: Vec<PlannedJob>, |
| 202 | config_error: Option<&str>, |
| 203 | ) -> forge_core::Result<CiRun> { |
| 204 | let new_jobs: Vec<NewJob<'_>> = jobs |
| 205 | .iter() |
| 206 | .map(|(name, spec_json, needs)| NewJob { |
| 207 | name, |
| 208 | spec_json, |
| 209 | needs: needs.as_slice(), |
| 210 | }) |
| 211 | .collect(); |
| 212 | let run = state |
| 213 | .store |
| 214 | .create_run( |
| 215 | &req.repo.id, |
| 216 | req.trigger, |
| 217 | req.git_ref, |
| 218 | req.commit, |
| 219 | req.pull_number, |
| 220 | req.actor_id, |
| 221 | req.needs_approval, |
| 222 | &new_jobs, |
| 223 | config_error, |
| 224 | ) |
| 225 | .await?; |
| 226 | if let Some(message) = config_error { |
| 227 | let url = format!( |
| 228 | "{}/{}/{}/ci/runs/{}", |
| 229 | state.config.base_url.trim_end_matches('/'), |
| 230 | req.repo.owner_name, |
| 231 | req.repo.name, |
| 232 | run.number |
| 233 | ); |
| 234 | publish( |
| 235 | state, |
| 236 | req.repo, |
| 237 | &run, |
| 238 | "ci/config", |
| 239 | StatusState::Error, |
| 240 | message, |
| 241 | &url, |
| 242 | ) |
| 243 | .await; |
| 244 | } |
| 245 | if sync_run_statuses(state, &run).await? { |
| 246 | state.ci_waker.notify_waiters(); |
| 247 | } |
| 248 | Ok(run) |
| 249 | } |
| 250 | |
| 251 | |
| 252 | fn plan_for(loaded: Loaded, event: &Event<'_>) -> forge_core::Result<Plan> { |
| 253 | match loaded { |
| 254 | Loaded::Missing => Ok(Plan::Missing), |
| 255 | Loaded::Invalid(message) => Ok(Plan::Run { |
| 256 | jobs: Vec::new(), |
| 257 | config_error: Some(message), |
| 258 | }), |
| 259 | Loaded::Valid(cfg) => { |
| 260 | if !cfg.triggered_by(event) { |
| 261 | return Ok(Plan::NotTriggered); |
| 262 | } |
| 263 | Ok(Plan::Run { |
| 264 | jobs: plan_jobs(&cfg)?, |
| 265 | config_error: None, |
| 266 | }) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | |
| 272 | fn plan_jobs(cfg: &CiConfig) -> forge_core::Result<Vec<PlannedJob>> { |
| 273 | let mut jobs = Vec::new(); |
| 274 | for name in cfg.job_order() { |
| 275 | let Some(spec) = cfg.jobs.get(name) else { |
| 276 | continue; |
| 277 | }; |
| 278 | let spec_json = |
| 279 | serde_json::to_string(spec).map_err(|e| forge_core::Error::Invalid(e.to_string()))?; |
| 280 | jobs.push((name.to_owned(), spec_json, spec.needs.clone())); |
| 281 | } |
| 282 | Ok(jobs) |
| 283 | } |
| 284 | |
| 285 | |
| 286 | async fn load(state: &AppState, repo_id: &str, commit: &str) -> Loaded { |
| 287 | let repo_dir = match forge_git::repo::repo_path(&state.git.data_dir, repo_id) { |
| 288 | Ok(path) => path, |
| 289 | Err(e) => { |
| 290 | tracing::error!(error = %e, repo_id, "ci: bad repo path"); |
| 291 | return Loaded::Invalid(format!("could not read {CONFIG_PATH}")); |
| 292 | } |
| 293 | }; |
| 294 | let (dir, rev) = (repo_dir, commit.to_owned()); |
| 295 | let blob = match blocking(move || read_blob(&dir, &rev, CONFIG_PATH, MAX_CONFIG_BYTES)).await { |
| 296 | Ok(blob) => blob, |
| 297 | Err(e) => { |
| 298 | tracing::error!(error = %e, repo_id, commit, "ci: reading the workflow failed"); |
| 299 | return Loaded::Invalid(format!("could not read {CONFIG_PATH}")); |
| 300 | } |
| 301 | }; |
| 302 | let Some(blob) = blob else { |
| 303 | return Loaded::Missing; |
| 304 | }; |
| 305 | if blob.truncated { |
| 306 | return Loaded::Invalid(format!("{CONFIG_PATH} is larger than 64 KiB")); |
| 307 | } |
| 308 | if blob.is_binary { |
| 309 | return Loaded::Invalid(format!("{CONFIG_PATH} is not text")); |
| 310 | } |
| 311 | let Ok(text) = String::from_utf8(blob.data) else { |
| 312 | return Loaded::Invalid(format!("{CONFIG_PATH} is not valid UTF-8")); |
| 313 | }; |
| 314 | match parse_config(&text) { |
| 315 | Ok(cfg) => Loaded::Valid(cfg), |
| 316 | Err(e) => Loaded::Invalid(e.to_string()), |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | |
| 321 | |
| 322 | async fn needs_approval( |
| 323 | state: &AppState, |
| 324 | repo: &Repo, |
| 325 | pull: &PullRequest, |
| 326 | ) -> forge_core::Result<bool> { |
| 327 | if pull.head_repo_id.as_deref() != Some(repo.id.as_str()) { |
| 328 | return Ok(true); |
| 329 | } |
| 330 | let Some(author) = state.store.user_by_id(&pull.issue.author_id).await? else { |
| 331 | return Ok(true); |
| 332 | }; |
| 333 | if author.is_org() { |
| 334 | return Ok(true); |
| 335 | } |
| 336 | let role = state.store.collaborator_role(&repo.id, &author.id).await?; |
| 337 | let org_role = state |
| 338 | .store |
| 339 | .org_repo_role(&repo.owner_id, &author.id, &repo.id) |
| 340 | .await?; |
| 341 | Ok(authorize(Some(&author), repo, role, org_role, Action::Write).is_err()) |
| 342 | } |
| 343 | |
| 344 | |
| 345 | async fn blocking<T, F>(work: F) -> forge_core::Result<T> |
| 346 | where |
| 347 | F: FnOnce() -> forge_git::Result<T> + Send + 'static, |
| 348 | T: Send + 'static, |
| 349 | { |
| 350 | match tokio::task::spawn_blocking(work).await { |
| 351 | Ok(Ok(value)) => Ok(value), |
| 352 | Ok(Err(e)) => Err(forge_core::Error::Git(e.to_string())), |
| 353 | Err(e) => Err(forge_core::Error::Git(format!("git task failed: {e}"))), |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | #[cfg(test)] |
| 358 | mod tests { |
| 359 | use std::path::{Path, PathBuf}; |
| 360 | use std::sync::Arc; |
| 361 | use std::time::Duration; |
| 362 | |
| 363 | use forge_core::ci::{CiJob, CiRunner, JobState, RunState}; |
| 364 | use forge_core::store::commit_status::{CommitStatus, StatusState}; |
| 365 | use forge_core::time::stamp_after; |
| 366 | use forge_core::{User, Visibility}; |
| 367 | use forge_git::repo::{init_bare, repo_path}; |
| 368 | use tempfile::TempDir; |
| 369 | |
| 370 | use super::*; |
| 371 | use crate::ci::log_path; |
| 372 | use crate::ci::reaper::{purge_once, reap_before}; |
| 373 | use crate::post_update::after_ref_update; |
| 374 | use crate::test_support::test_state; |
| 375 | |
| 376 | const ZERO: &str = "0000000000000000000000000000000000000000"; |
| 377 | |
| 378 | const CONFIG: &str = r#" |
| 379 | [on] |
| 380 | push = ["main"] |
| 381 | pull_request = true |
| 382 | |
| 383 | [jobs.build] |
| 384 | image = "alpine:3" |
| 385 | run = "make" |
| 386 | |
| 387 | [jobs.test] |
| 388 | image = "alpine:3" |
| 389 | run = "make test" |
| 390 | needs = ["build"] |
| 391 | "#; |
| 392 | |
| 393 | const CONFIG_GLOBS: &str = r#" |
| 394 | [on] |
| 395 | push = ["release/*"] |
| 396 | |
| 397 | [jobs.build] |
| 398 | image = "alpine:3" |
| 399 | run = "make" |
| 400 | "#; |
| 401 | |
| 402 | const CONFIG_TAGS: &str = r#" |
| 403 | [on] |
| 404 | push = [] |
| 405 | tags = ["v*"] |
| 406 | |
| 407 | [jobs.build] |
| 408 | image = "alpine:3" |
| 409 | run = "make" |
| 410 | "#; |
| 411 | |
| 412 | const CONFIG_ONE_JOB: &str = r#" |
| 413 | [on] |
| 414 | push = ["main"] |
| 415 | |
| 416 | [jobs.build] |
| 417 | image = "alpine:3" |
| 418 | run = "make" |
| 419 | "#; |
| 420 | |
| 421 | const CONFIG_NO_MANUAL: &str = r#" |
| 422 | [on] |
| 423 | push = ["main"] |
| 424 | manual = false |
| 425 | |
| 426 | [jobs.build] |
| 427 | image = "alpine:3" |
| 428 | run = "make" |
| 429 | "#; |
| 430 | |
| 431 | fn git(dir: &Path, args: &[&str]) -> String { |
| 432 | let out = std::process::Command::new("git") |
| 433 | .arg("-C") |
| 434 | .arg(dir) |
| 435 | .args(args) |
| 436 | .env("GIT_AUTHOR_NAME", "test") |
| 437 | .env("GIT_AUTHOR_EMAIL", "test@example.com") |
| 438 | .env("GIT_COMMITTER_NAME", "test") |
| 439 | .env("GIT_COMMITTER_EMAIL", "test@example.com") |
| 440 | .output() |
| 441 | .expect("git runs"); |
| 442 | assert!( |
| 443 | out.status.success(), |
| 444 | "git {args:?} failed: {}", |
| 445 | String::from_utf8_lossy(&out.stderr) |
| 446 | ); |
| 447 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 448 | } |
| 449 | |
| 450 | struct Fixture { |
| 451 | _tmp: TempDir, |
| 452 | state: AppState, |
| 453 | repo: Repo, |
| 454 | owner: User, |
| 455 | bare: PathBuf, |
| 456 | work: PathBuf, |
| 457 | } |
| 458 | |
| 459 | async fn fixture() -> Fixture { |
| 460 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 461 | let state = test_state(tmp.path()).await; |
| 462 | let owner = state |
| 463 | .store |
| 464 | .create_user("alice", None, "unused", false) |
| 465 | .await |
| 466 | .expect("user"); |
| 467 | let repo = state |
| 468 | .store |
| 469 | .create_repo(&owner.id, "proj", "", Visibility::Private, "main") |
| 470 | .await |
| 471 | .expect("repo"); |
| 472 | let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 473 | init_bare(&state.git, &bare, "main") |
| 474 | .await |
| 475 | .expect("init bare"); |
| 476 | let work = tmp.path().join("work"); |
| 477 | git(tmp.path(), &["init", "-b", "main", "work"]); |
| 478 | Fixture { |
| 479 | _tmp: tmp, |
| 480 | state, |
| 481 | repo, |
| 482 | owner, |
| 483 | bare, |
| 484 | work, |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | |
| 489 | fn commit(work: &Path, files: &[(&str, &str)]) -> String { |
| 490 | for (path, text) in files { |
| 491 | let full = work.join(path); |
| 492 | if let Some(parent) = full.parent() { |
| 493 | std::fs::create_dir_all(parent).expect("dirs"); |
| 494 | } |
| 495 | std::fs::write(&full, text).expect("write"); |
| 496 | git(work, &["add", path]); |
| 497 | } |
| 498 | git(work, &["commit", "--allow-empty", "-m", "change"]); |
| 499 | git(work, &["rev-parse", "HEAD"]) |
| 500 | } |
| 501 | |
| 502 | |
| 503 | fn land(fx: &Fixture, oid: &str, refname: &str) { |
| 504 | let work = fx.work.to_str().expect("utf8 path"); |
| 505 | git( |
| 506 | &fx.bare, |
| 507 | &["fetch", "--no-tags", work, &format!("+{oid}:{refname}")], |
| 508 | ); |
| 509 | } |
| 510 | |
| 511 | |
| 512 | async fn push(fx: &Fixture, files: &[(&str, &str)], refname: &str) -> String { |
| 513 | let oid = commit(&fx.work, files); |
| 514 | land(fx, &oid, refname); |
| 515 | after_ref_update(&fx.state, &fx.repo, &fx.owner.id, refname, ZERO, &oid) |
| 516 | .await |
| 517 | .expect("post update"); |
| 518 | oid |
| 519 | } |
| 520 | |
| 521 | fn status<'a>(statuses: &'a [CommitStatus], context: &str) -> Option<&'a CommitStatus> { |
| 522 | statuses.iter().find(|s| s.context == context) |
| 523 | } |
| 524 | |
| 525 | fn state_of(jobs: &[CiJob], name: &str) -> Option<JobState> { |
| 526 | jobs.iter().find(|j| j.name == name).map(|j| j.state) |
| 527 | } |
| 528 | |
| 529 | async fn runs_total(fx: &Fixture) -> u64 { |
| 530 | fx.state |
| 531 | .store |
| 532 | .list_runs(&fx.repo.id, 1, 50) |
| 533 | .await |
| 534 | .expect("list runs") |
| 535 | .1 |
| 536 | } |
| 537 | |
| 538 | async fn runner(fx: &Fixture) -> CiRunner { |
| 539 | let token = fx |
| 540 | .state |
| 541 | .store |
| 542 | .create_registration_token(&fx.owner.id, 24) |
| 543 | .await |
| 544 | .expect("token"); |
| 545 | let (runner, _secret) = fx |
| 546 | .state |
| 547 | .store |
| 548 | .register_runner(&token, "runner-1", &[]) |
| 549 | .await |
| 550 | .expect("register"); |
| 551 | runner |
| 552 | } |
| 553 | |
| 554 | #[tokio::test] |
| 555 | async fn push_with_config_creates_run_and_pending_statuses() { |
| 556 | let fx = fixture().await; |
| 557 | let oid = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/main").await; |
| 558 | |
| 559 | let run = fx |
| 560 | .state |
| 561 | .store |
| 562 | .run_by_number(&fx.repo.id, 1) |
| 563 | .await |
| 564 | .expect("query") |
| 565 | .expect("run exists"); |
| 566 | assert_eq!(run.trigger, "push"); |
| 567 | assert_eq!(run.git_ref, "refs/heads/main"); |
| 568 | assert_eq!(run.commit_oid, oid); |
| 569 | assert!(run.config_error.is_none()); |
| 570 | |
| 571 | let jobs = fx.state.store.jobs_for_run(&run.id).await.expect("jobs"); |
| 572 | assert_eq!(state_of(&jobs, "build"), Some(JobState::Queued)); |
| 573 | assert_eq!(state_of(&jobs, "test"), Some(JobState::Blocked)); |
| 574 | |
| 575 | let statuses = fx |
| 576 | .state |
| 577 | .store |
| 578 | .statuses_for(&fx.repo.id, &oid) |
| 579 | .await |
| 580 | .expect("statuses"); |
| 581 | let build = status(&statuses, "ci/build").expect("ci/build status"); |
| 582 | assert_eq!(build.state, StatusState::Pending); |
| 583 | let url = build.target_url.clone().unwrap_or_default(); |
| 584 | let job = jobs.iter().find(|j| j.name == "build").expect("build job"); |
| 585 | assert_eq!( |
| 586 | url, |
| 587 | format!("https://forge.test/alice/proj/ci/jobs/{}", job.id) |
| 588 | ); |
| 589 | let test = status(&statuses, "ci/test").expect("ci/test status"); |
| 590 | assert_eq!(test.state, StatusState::Pending); |
| 591 | } |
| 592 | |
| 593 | #[tokio::test] |
| 594 | async fn no_file_means_no_run() { |
| 595 | let fx = fixture().await; |
| 596 | let oid = push(&fx, &[("README.md", "hello")], "refs/heads/main").await; |
| 597 | assert_eq!(runs_total(&fx).await, 0); |
| 598 | let statuses = fx |
| 599 | .state |
| 600 | .store |
| 601 | .statuses_for(&fx.repo.id, &oid) |
| 602 | .await |
| 603 | .expect("statuses"); |
| 604 | assert!(statuses.is_empty()); |
| 605 | } |
| 606 | |
| 607 | #[tokio::test] |
| 608 | async fn bad_file_gives_error_run_and_config_status() { |
| 609 | let fx = fixture().await; |
| 610 | let oid = push( |
| 611 | &fx, |
| 612 | &[(CONFIG_PATH, "[jobs.build\nimage = ")], |
| 613 | "refs/heads/main", |
| 614 | ) |
| 615 | .await; |
| 616 | let run = fx |
| 617 | .state |
| 618 | .store |
| 619 | .run_by_number(&fx.repo.id, 1) |
| 620 | .await |
| 621 | .expect("query") |
| 622 | .expect("run exists"); |
| 623 | assert_eq!(run.state, RunState::Error); |
| 624 | assert!(run.config_error.is_some()); |
| 625 | let jobs = fx.state.store.jobs_for_run(&run.id).await.expect("jobs"); |
| 626 | assert!(jobs.is_empty()); |
| 627 | |
| 628 | let statuses = fx |
| 629 | .state |
| 630 | .store |
| 631 | .statuses_for(&fx.repo.id, &oid) |
| 632 | .await |
| 633 | .expect("statuses"); |
| 634 | let config = status(&statuses, "ci/config").expect("ci/config status"); |
| 635 | assert_eq!(config.state, StatusState::Error); |
| 636 | assert!(!config.description.as_deref().unwrap_or("").is_empty()); |
| 637 | } |
| 638 | |
| 639 | #[tokio::test] |
| 640 | async fn branch_globs_select_runs() { |
| 641 | let fx = fixture().await; |
| 642 | push(&fx, &[(CONFIG_PATH, CONFIG_GLOBS)], "refs/heads/main").await; |
| 643 | assert_eq!(runs_total(&fx).await, 0, "main is not in the globs"); |
| 644 | |
| 645 | push( |
| 646 | &fx, |
| 647 | &[(CONFIG_PATH, CONFIG_GLOBS)], |
| 648 | "refs/heads/release/1.0", |
| 649 | ) |
| 650 | .await; |
| 651 | assert_eq!(runs_total(&fx).await, 1); |
| 652 | let run = fx |
| 653 | .state |
| 654 | .store |
| 655 | .run_by_number(&fx.repo.id, 1) |
| 656 | .await |
| 657 | .expect("query") |
| 658 | .expect("run"); |
| 659 | assert_eq!(run.git_ref, "refs/heads/release/1.0"); |
| 660 | } |
| 661 | |
| 662 | #[tokio::test] |
| 663 | async fn tag_push_creates_tag_run() { |
| 664 | let fx = fixture().await; |
| 665 | push(&fx, &[(CONFIG_PATH, CONFIG_TAGS)], "refs/tags/nightly").await; |
| 666 | assert_eq!(runs_total(&fx).await, 0, "nightly does not match v*"); |
| 667 | |
| 668 | push(&fx, &[(CONFIG_PATH, CONFIG_TAGS)], "refs/tags/v1.0").await; |
| 669 | assert_eq!(runs_total(&fx).await, 1); |
| 670 | let run = fx |
| 671 | .state |
| 672 | .store |
| 673 | .run_by_number(&fx.repo.id, 1) |
| 674 | .await |
| 675 | .expect("query") |
| 676 | .expect("run"); |
| 677 | assert_eq!(run.trigger, "tag"); |
| 678 | assert_eq!(run.git_ref, "refs/tags/v1.0"); |
| 679 | } |
| 680 | |
| 681 | |
| 682 | |
| 683 | async fn open_pull( |
| 684 | fx: &Fixture, |
| 685 | author_id: &str, |
| 686 | head_repo_id: &str, |
| 687 | branch: &str, |
| 688 | head_oid: &str, |
| 689 | ) -> PullRequest { |
| 690 | let pr = fx |
| 691 | .state |
| 692 | .store |
| 693 | .create_pull( |
| 694 | &fx.repo.id, |
| 695 | author_id, |
| 696 | "Change", |
| 697 | "", |
| 698 | "main", |
| 699 | head_repo_id, |
| 700 | branch, |
| 701 | head_oid, |
| 702 | head_oid, |
| 703 | ) |
| 704 | .await |
| 705 | .expect("create pull"); |
| 706 | on_pull_event(&fx.state, &fx.repo, &pr, author_id).await; |
| 707 | pr |
| 708 | } |
| 709 | |
| 710 | #[tokio::test] |
| 711 | async fn fork_pull_request_waits_for_approval() { |
| 712 | let fx = fixture().await; |
| 713 | let head = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/feature").await; |
| 714 | let bob = fx |
| 715 | .state |
| 716 | .store |
| 717 | .create_user("bob", None, "unused", false) |
| 718 | .await |
| 719 | .expect("user"); |
| 720 | let fork = fx |
| 721 | .state |
| 722 | .store |
| 723 | .create_repo(&bob.id, "proj", "", Visibility::Public, "main") |
| 724 | .await |
| 725 | .expect("fork"); |
| 726 | let pr = open_pull(&fx, &fx.owner.id, &fork.id, "feature", &head).await; |
| 727 | |
| 728 | let run = fx |
| 729 | .state |
| 730 | .store |
| 731 | .run_by_number(&fx.repo.id, 1) |
| 732 | .await |
| 733 | .expect("query") |
| 734 | .expect("run"); |
| 735 | assert_eq!(run.trigger, "pull_request"); |
| 736 | assert_eq!(run.state, RunState::WaitingApproval); |
| 737 | assert_eq!(run.pull_number, Some(pr.issue.number)); |
| 738 | |
| 739 | let statuses = fx |
| 740 | .state |
| 741 | .store |
| 742 | .statuses_for(&fx.repo.id, &head) |
| 743 | .await |
| 744 | .expect("statuses"); |
| 745 | let build = status(&statuses, "ci/build").expect("ci/build"); |
| 746 | assert_eq!(build.state, StatusState::Pending); |
| 747 | assert_eq!(build.description.as_deref(), Some("Waiting for approval")); |
| 748 | } |
| 749 | |
| 750 | #[tokio::test] |
| 751 | async fn non_writer_pull_request_waits_for_approval() { |
| 752 | let fx = fixture().await; |
| 753 | let head = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/feature").await; |
| 754 | let bob = fx |
| 755 | .state |
| 756 | .store |
| 757 | .create_user("bob", None, "unused", false) |
| 758 | .await |
| 759 | .expect("user"); |
| 760 | open_pull(&fx, &bob.id, &fx.repo.id, "feature", &head).await; |
| 761 | let run = fx |
| 762 | .state |
| 763 | .store |
| 764 | .run_by_number(&fx.repo.id, 1) |
| 765 | .await |
| 766 | .expect("query") |
| 767 | .expect("run"); |
| 768 | assert_eq!(run.state, RunState::WaitingApproval); |
| 769 | } |
| 770 | |
| 771 | #[tokio::test] |
| 772 | async fn writer_pull_request_runs_at_once() { |
| 773 | let fx = fixture().await; |
| 774 | let head = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/feature").await; |
| 775 | open_pull(&fx, &fx.owner.id, &fx.repo.id, "feature", &head).await; |
| 776 | let run = fx |
| 777 | .state |
| 778 | .store |
| 779 | .run_by_number(&fx.repo.id, 1) |
| 780 | .await |
| 781 | .expect("query") |
| 782 | .expect("run"); |
| 783 | assert_ne!(run.state, RunState::WaitingApproval); |
| 784 | } |
| 785 | |
| 786 | #[tokio::test] |
| 787 | async fn manual_run_uses_branch_head() { |
| 788 | let fx = fixture().await; |
| 789 | let head = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/main").await; |
| 790 | let before = runs_total(&fx).await; |
| 791 | let run = manual_run(&fx.state, &fx.repo, "main", &fx.owner.id) |
| 792 | .await |
| 793 | .expect("manual run"); |
| 794 | assert_eq!(run.trigger, "manual"); |
| 795 | assert_eq!(run.git_ref, "refs/heads/main"); |
| 796 | assert_eq!(run.commit_oid, head); |
| 797 | assert_eq!(runs_total(&fx).await, before + 1); |
| 798 | } |
| 799 | |
| 800 | #[tokio::test] |
| 801 | async fn manual_run_refuses_missing_branch_and_disabled_manual() { |
| 802 | let fx = fixture().await; |
| 803 | push(&fx, &[(CONFIG_PATH, CONFIG_NO_MANUAL)], "refs/heads/main").await; |
| 804 | let before = runs_total(&fx).await; |
| 805 | assert!( |
| 806 | manual_run(&fx.state, &fx.repo, "nope", &fx.owner.id) |
| 807 | .await |
| 808 | .is_err() |
| 809 | ); |
| 810 | let refused = manual_run(&fx.state, &fx.repo, "main", &fx.owner.id).await; |
| 811 | assert!(matches!(refused, Err(forge_core::Error::Invalid(_)))); |
| 812 | assert_eq!(runs_total(&fx).await, before); |
| 813 | } |
| 814 | |
| 815 | #[tokio::test] |
| 816 | async fn reaper_marks_lost_jobs_and_syncs_statuses() { |
| 817 | let fx = fixture().await; |
| 818 | let head = push(&fx, &[(CONFIG_PATH, CONFIG)], "refs/heads/main").await; |
| 819 | let runner = runner(&fx).await; |
| 820 | let (job, _run, _token) = fx |
| 821 | .state |
| 822 | .store |
| 823 | .claim_job(&runner) |
| 824 | .await |
| 825 | .expect("claim") |
| 826 | .expect("a queued job"); |
| 827 | assert_eq!(job.name, "build"); |
| 828 | |
| 829 | |
| 830 | let reaped = reap_before(&fx.state, &stamp_after(Duration::from_secs(3600))).await; |
| 831 | assert_eq!(reaped, 1); |
| 832 | |
| 833 | let job = fx |
| 834 | .state |
| 835 | .store |
| 836 | .job_by_id(&job.id) |
| 837 | .await |
| 838 | .expect("query") |
| 839 | .expect("job"); |
| 840 | assert_eq!(job.state, JobState::Error); |
| 841 | assert_eq!(job.message.as_deref(), Some("runner lost")); |
| 842 | |
| 843 | let statuses = fx |
| 844 | .state |
| 845 | .store |
| 846 | .statuses_for(&fx.repo.id, &head) |
| 847 | .await |
| 848 | .expect("statuses"); |
| 849 | let build = status(&statuses, "ci/build").expect("ci/build"); |
| 850 | assert_eq!(build.state, StatusState::Error); |
| 851 | } |
| 852 | |
| 853 | #[tokio::test] |
| 854 | async fn purge_deletes_log_files() { |
| 855 | let fx = fixture().await; |
| 856 | push(&fx, &[(CONFIG_PATH, CONFIG_ONE_JOB)], "refs/heads/main").await; |
| 857 | let runner = runner(&fx).await; |
| 858 | let (job, _run, _token) = fx |
| 859 | .state |
| 860 | .store |
| 861 | .claim_job(&runner) |
| 862 | .await |
| 863 | .expect("claim") |
| 864 | .expect("a queued job"); |
| 865 | |
| 866 | fx.state |
| 867 | .store |
| 868 | .add_log_bytes(&job.id, 12) |
| 869 | .await |
| 870 | .expect("log bytes"); |
| 871 | fx.state |
| 872 | .store |
| 873 | .finish_job(&job.id, JobState::Success, Some(0), None) |
| 874 | .await |
| 875 | .expect("finish"); |
| 876 | |
| 877 | let path = log_path(&fx.state.config.data_dir, &job.id); |
| 878 | std::fs::create_dir_all(path.parent().expect("parent")).expect("log dir"); |
| 879 | std::fs::write(&path, "build output\n").expect("write log"); |
| 880 | |
| 881 | |
| 882 | let mut cfg = (*fx.state.config).clone(); |
| 883 | cfg.ci.log_retention_days = 0; |
| 884 | let mut state = fx.state.clone(); |
| 885 | state.config = Arc::new(cfg); |
| 886 | tokio::time::sleep(Duration::from_millis(1100)).await; |
| 887 | |
| 888 | let (logs, _artifacts) = purge_once(&state).await; |
| 889 | assert_eq!(logs, 1); |
| 890 | assert!(!path.exists(), "log file is deleted"); |
| 891 | } |
| 892 | } |