This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-postreceive / tests / post_receive.rs
21 kB 709 lines
1use std::path::Path; 2use std::time::Duration; 3 4use knot_events::{EventCursor, EventLog, Reservation}; 5use knot_git::{Layout, RefUpdate, Repo}; 6use knot_postreceive::{Actor, Ci, LanguagesPushBudget, OwnerLabel, PullLink, post_receive}; 7use knot_runtime::{ManualClock, UnixMicros}; 8use knot_types::{ 9 AccountDid, AppviewEndpoint, BranchName, CiLogsAddr, Handle, Oid, OriginUrl, OwnerDid, 10 PushOption, PushOptions, RefName, RepoDid, RepoRkey, 11}; 12 13const DID: &str = "did:plc:limpet"; 14const OWNER: &str = "did:web:olaren.dev"; 15const COMMITTER: &str = "did:plc:nel"; 16const PUSH_BUDGET: LanguagesPushBudget = LanguagesPushBudget::new(Duration::from_secs(2)); 17 18fn git(cwd: &Path, args: &[&str]) -> String { 19 let output = knot_fixtures::command(cwd) 20 .args(args) 21 .output() 22 .expect("git is available"); 23 assert!( 24 output.status.success(), 25 "git {args:?} failed:\n{}", 26 String::from_utf8_lossy(&output.stderr) 27 ); 28 String::from_utf8(output.stdout).unwrap().trim().to_string() 29} 30 31fn commit_file(work: &Path, file: &str, contents: &str, message: &str) { 32 let path = work.join(file); 33 if let Some(parent) = path.parent() { 34 std::fs::create_dir_all(parent).unwrap(); 35 } 36 std::fs::write(path, contents).unwrap(); 37 git(work, &["add", "-A"]); 38 git(work, &["commit", "-q", "-m", message]); 39} 40 41struct World { 42 _scan: tempfile::TempDir, 43 _work: tempfile::TempDir, 44 repo: Repo, 45 work: std::path::PathBuf, 46 bare: std::path::PathBuf, 47} 48 49fn world() -> World { 50 let scan = tempfile::tempdir().unwrap(); 51 let layout = Layout::new(scan.path()).with_default_branch(BranchName::new("main").unwrap()); 52 let did = RepoDid::new(DID).unwrap(); 53 layout.create(&did).unwrap(); 54 let bare = layout.repo_path(&did).unwrap(); 55 56 let work_dir = tempfile::tempdir().unwrap(); 57 let work = work_dir.path().to_path_buf(); 58 git(&work, &["init", "-q", "-b", "main"]); 59 let repo = layout.open(&did).unwrap(); 60 World { 61 _scan: scan, 62 _work: work_dir, 63 repo, 64 work, 65 bare, 66 } 67} 68 69fn push(world: &World, branch: &str) { 70 git( 71 &world.work, 72 &["push", "-q", world.bare.to_str().unwrap(), branch], 73 ); 74} 75 76fn oid(world: &World, rev: &str) -> Oid { 77 Oid::from_hex(&git(&world.work, &["rev-parse", rev])).unwrap() 78} 79 80fn actor() -> Actor { 81 Actor { 82 committer: AccountDid::new(COMMITTER).unwrap(), 83 owner: Some(OwnerDid::new(OWNER).unwrap()), 84 repo: RepoDid::new(DID).unwrap(), 85 } 86} 87 88fn refname(name: &str) -> RefName { 89 RefName::new(name).unwrap() 90} 91 92fn pull() -> PullLink { 93 PullLink { 94 appview: AppviewEndpoint::new("https://tangled.test").unwrap(), 95 owner: OwnerLabel::Handle(Handle::new_owned("nel.pet").unwrap()), 96 rkey: RepoRkey::new("anemone").unwrap(), 97 } 98} 99 100fn compile(verbose: bool) -> Ci { 101 Ci::Compile { 102 logs: None, 103 verbose, 104 } 105} 106 107fn compile_with_logs(addr: &str) -> Ci { 108 Ci::Compile { 109 logs: Some(CiLogsAddr::new(addr).unwrap()), 110 verbose: false, 111 } 112} 113 114fn bounds() -> knot_events::ReplayBounds { 115 knot_events::ReplayBounds::new( 116 knot_events::ReplayEvents::new(32).unwrap(), 117 knot_events::ReplayBytes::new(16 << 20).unwrap(), 118 ) 119} 120 121fn log() -> EventLog<ManualClock> { 122 EventLog::new(ManualClock::new(UnixMicros::new(1_000_000_000)), bounds()) 123} 124 125fn reserved(log: &EventLog<ManualClock>, applied: &[RefUpdate]) -> Vec<(RefUpdate, Reservation)> { 126 applied 127 .iter() 128 .map(|update| (update.clone(), log.reserve())) 129 .collect() 130} 131 132fn events(log: &EventLog<ManualClock>) -> Vec<(String, serde_json::Value)> { 133 log.replay(EventCursor::START, bounds()) 134 .events 135 .into_iter() 136 .map(|event| { 137 let wire = serde_json::to_value(&*event).unwrap(); 138 (event.nsid.to_string(), wire["event"].clone()) 139 }) 140 .collect() 141} 142 143fn run( 144 world: &World, 145 log: &EventLog<ManualClock>, 146 applied: &[RefUpdate], 147 ci: &Ci, 148 pull: Option<&PullLink>, 149) -> Vec<String> { 150 run_with_options(world, log, applied, ci, &PushOptions::default(), pull) 151} 152 153fn run_with_options( 154 world: &World, 155 log: &EventLog<ManualClock>, 156 applied: &[RefUpdate], 157 ci: &Ci, 158 push_options: &PushOptions, 159 pull: Option<&PullLink>, 160) -> Vec<String> { 161 let repo = Repo::open(&world.bare).unwrap(); 162 post_receive( 163 &repo, 164 &actor(), 165 reserved(log, applied), 166 ci, 167 push_options, 168 pull, 169 PUSH_BUDGET, 170 &knot_messages::default_catalog().push, 171 ) 172} 173 174fn created(branch: &str, head: Oid) -> [RefUpdate; 1] { 175 [RefUpdate::Create { 176 name: refname(&format!("refs/heads/{branch}")), 177 new: head, 178 }] 179} 180 181fn create(world: &World, branch: &str) -> [RefUpdate; 1] { 182 commit_file(&world.work, "a.txt", "one\n", "first"); 183 push(world, branch); 184 created(branch, oid(world, "HEAD")) 185} 186 187fn create_feature(world: &World) -> Oid { 188 commit_file(&world.work, "a.txt", "one\n", "first"); 189 push(world, "main"); 190 git(&world.work, &["checkout", "-q", "-b", "feature"]); 191 commit_file(&world.work, "c.txt", "three\n", "feature work"); 192 push(world, "feature"); 193 oid(world, "HEAD") 194} 195 196#[test] 197fn a_create_emits_a_ref_update_with_commit_counts_and_default_ref() { 198 let world = world(); 199 commit_file(&world.work, "a.txt", "one\n", "first"); 200 commit_file(&world.work, "b.txt", "two\n", "second"); 201 push(&world, "main"); 202 let head = oid(&world, "HEAD"); 203 204 let log = log(); 205 let applied = created("main", head); 206 run(&world, &log, &applied, &Ci::Skip, None); 207 208 let events = events(&log); 209 assert_eq!(events.len(), 1); 210 let (nsid, payload) = &events[0]; 211 assert_eq!(nsid, "sh.tangled.git.refUpdate"); 212 assert_eq!(payload["ref"], "refs/heads/main"); 213 assert_eq!(payload["newSha"], head.to_hex()); 214 assert_eq!( 215 payload["oldSha"], 216 world.repo.object_format().null_oid().to_string() 217 ); 218 assert_eq!(payload["committerDid"], COMMITTER); 219 assert_eq!(payload["meta"]["isDefaultRef"], true); 220 assert_eq!( 221 payload["meta"]["commitCount"]["byEmail"][0]["email"], 222 "nel@oyster.cafe" 223 ); 224 assert_eq!(payload["meta"]["commitCount"]["byEmail"][0]["count"], 2); 225} 226 227#[test] 228fn an_update_counts_only_the_new_commits() { 229 let world = world(); 230 commit_file(&world.work, "a.txt", "one\n", "first"); 231 push(&world, "main"); 232 let old = oid(&world, "HEAD"); 233 commit_file(&world.work, "b.txt", "two\n", "second"); 234 push(&world, "main"); 235 let new = oid(&world, "HEAD"); 236 237 let log = log(); 238 let applied = [RefUpdate::Update { 239 name: refname("refs/heads/main"), 240 old, 241 new, 242 }]; 243 run(&world, &log, &applied, &Ci::Skip, None); 244 245 let payload = &events(&log)[0].1; 246 assert_eq!(payload["meta"]["commitCount"]["byEmail"][0]["count"], 1); 247} 248 249#[test] 250fn a_non_default_branch_is_not_flagged_default() { 251 let world = world(); 252 commit_file(&world.work, "a.txt", "one\n", "first"); 253 push(&world, "main"); 254 git(&world.work, &["checkout", "-q", "-b", "feature"]); 255 commit_file(&world.work, "c.txt", "three\n", "feature work"); 256 push(&world, "feature"); 257 let head = oid(&world, "HEAD"); 258 259 let log = log(); 260 let applied = created("feature", head); 261 run(&world, &log, &applied, &Ci::Skip, None); 262 263 let payload = &events(&log)[0].1; 264 assert_eq!(payload["meta"]["isDefaultRef"], false); 265 assert_eq!(payload["meta"]["commitCount"]["byEmail"][0]["count"], 1); 266} 267 268#[test] 269fn a_large_push_counts_every_commit_without_a_limit() { 270 let world = world(); 271 let count = 110; 272 (0..count).for_each(|n| { 273 git( 274 &world.work, 275 &["commit", "-q", "--allow-empty", "-m", &format!("c{n}")], 276 ); 277 }); 278 push(&world, "main"); 279 let head = oid(&world, "HEAD"); 280 281 let log = log(); 282 let applied = created("main", head); 283 run(&world, &log, &applied, &Ci::Skip, None); 284 285 let payload = &events(&log)[0].1; 286 assert_eq!( 287 payload["meta"]["commitCount"]["byEmail"][0]["count"], count, 288 "every commit is tallied, none dropped" 289 ); 290} 291 292#[test] 293fn a_non_default_branch_omits_the_language_breakdown() { 294 let world = world(); 295 commit_file(&world.work, "src/main.rs", "fn main() {}\n", "rust"); 296 push(&world, "main"); 297 git(&world.work, &["checkout", "-q", "-b", "feature"]); 298 commit_file(&world.work, "src/extra.rs", "fn extra() {}\n", "more rust"); 299 push(&world, "feature"); 300 let head = oid(&world, "HEAD"); 301 302 let log = log(); 303 let applied = created("feature", head); 304 run(&world, &log, &applied, &Ci::Skip, None); 305 306 let payload = &events(&log)[0].1; 307 assert_eq!(payload["meta"]["isDefaultRef"], false); 308 assert_eq!( 309 payload["meta"]["langBreakdown"], 310 serde_json::Value::Null, 311 "non-default ref has no language breakdown" 312 ); 313 assert_eq!(payload["meta"]["commitCount"]["byEmail"][0]["count"], 1); 314} 315 316#[test] 317fn a_delete_emits_a_ref_update_without_meta() { 318 let world = world(); 319 commit_file(&world.work, "a.txt", "one\n", "first"); 320 push(&world, "main"); 321 let old = oid(&world, "HEAD"); 322 323 let log = log(); 324 let applied = [RefUpdate::Delete { 325 name: refname("refs/heads/gone"), 326 old, 327 }]; 328 run(&world, &log, &applied, &Ci::Skip, None); 329 330 let payload = &events(&log)[0].1; 331 assert_eq!(payload["ref"], "refs/heads/gone"); 332 assert_eq!( 333 payload["newSha"], 334 world.repo.object_format().null_oid().to_string() 335 ); 336 assert_eq!(payload["meta"], serde_json::Value::Null); 337 assert!( 338 payload.get("changedFiles").is_none(), 339 "a deletion has no tree to diff: {payload}" 340 ); 341 assert!(payload.get("pushOptions").is_none(), "{payload}"); 342} 343 344#[test] 345fn language_breakdown_reports_pushed_sources() { 346 let world = world(); 347 commit_file( 348 &world.work, 349 "src/main.rs", 350 "fn main() {\n println!(\"hello from nel\");\n}\n", 351 "rust", 352 ); 353 push(&world, "main"); 354 let head = oid(&world, "HEAD"); 355 356 let log = log(); 357 let applied = created("main", head); 358 run(&world, &log, &applied, &Ci::Skip, None); 359 360 let payload = &events(&log)[0].1; 361 let langs = payload["meta"]["langBreakdown"]["inputs"] 362 .as_array() 363 .expect("language breakdown is present"); 364 assert!( 365 langs.iter().any(|lang| lang["lang"] == "Rust"), 366 "expected Rust in {langs:?}" 367 ); 368} 369 370#[test] 371fn a_configured_logs_address_yields_an_ssh_command_for_compiled_workflows() { 372 let world = world(); 373 commit_file( 374 &world.work, 375 ".tangled/workflows/ci.yml", 376 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n", 377 "add ci", 378 ); 379 push(&world, "main"); 380 let head = oid(&world, "HEAD"); 381 382 let log = log(); 383 let applied = created("main", head); 384 let messages = run( 385 &world, 386 &log, 387 &applied, 388 &compile_with_logs("logs.oyster.cafe:3333"), 389 None, 390 ); 391 392 assert!( 393 messages 394 .iter() 395 .any(|line| line.contains(&format!("ssh -t -p 3333 logs.oyster.cafe {DID} {head}"))), 396 "{messages:?}" 397 ); 398} 399 400#[test] 401fn a_push_without_workflows_yields_no_ssh_command_and_verbose_says_so() { 402 let world = world(); 403 let applied = create(&world, "main"); 404 let messages = run( 405 &world, 406 &log(), 407 &applied, 408 &compile_with_logs("logs.oyster.cafe:3333"), 409 None, 410 ); 411 assert!( 412 messages.iter().all(|line| !line.contains("ssh -t")), 413 "{messages:?}" 414 ); 415 416 let messages = run(&world, &log(), &applied, &compile(true), None); 417 assert!( 418 messages 419 .iter() 420 .any(|line| line == "no pipelines to compile"), 421 "{messages:?}" 422 ); 423 assert!( 424 messages 425 .iter() 426 .all(|line| line != "pipeline compiled with no diagnostics"), 427 "{messages:?}" 428 ); 429} 430 431#[test] 432fn verbose_keeps_the_warning_that_explains_why_nothing_compiled() { 433 let world = world(); 434 commit_file( 435 &world.work, 436 ".tangled/workflows/ci.yml", 437 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: [release]\n", 438 "add ci", 439 ); 440 push(&world, "main"); 441 let head = oid(&world, "HEAD"); 442 443 let log = log(); 444 let messages = run(&world, &log, &created("main", head), &compile(true), None); 445 446 assert!( 447 messages 448 .iter() 449 .any(|line| line.contains("workflow skipped")), 450 "a workflow that misses the trigger still reports why: {messages:?}" 451 ); 452 assert!( 453 messages 454 .iter() 455 .any(|line| line == "no pipelines to compile"), 456 "{messages:?}" 457 ); 458} 459 460#[test] 461fn the_ref_update_event_reports_changed_files_and_push_options() { 462 let world = world(); 463 commit_file(&world.work, "a.txt", "one\n", "first"); 464 commit_file(&world.work, "src/deep/main.rs", "fn main() {}\n", "nested"); 465 push(&world, "main"); 466 let head = oid(&world, "HEAD"); 467 git(&world.work, &["tag", "-a", "v1.0.0", "-m", "release one"]); 468 git( 469 &world.work, 470 &["push", "-q", "--tags", world.bare.to_str().unwrap()], 471 ); 472 let tag_object = oid(&world, "v1.0.0"); 473 assert_ne!(tag_object, oid(&world, "v1.0.0^{commit}")); 474 475 let branch_log = log(); 476 let options = PushOptions::new([PushOption::new("verbose-ci").unwrap()]); 477 run_with_options( 478 &world, 479 &branch_log, 480 &created("main", head), 481 &compile(false), 482 &options, 483 None, 484 ); 485 486 let payload = &events(&branch_log)[0].1; 487 assert_eq!( 488 payload["changedFiles"], 489 serde_json::json!(["a.txt", "src/deep/main.rs"]), 490 "a branch creation reports every blob in the tree and no directory of them" 491 ); 492 assert_eq!(payload["pushOptions"], serde_json::json!(["verbose-ci"])); 493 494 let tag_log = log(); 495 let applied = [RefUpdate::Create { 496 name: refname("refs/tags/v1.0.0"), 497 new: tag_object, 498 }]; 499 run(&world, &tag_log, &applied, &Ci::Skip, None); 500 assert_eq!( 501 events(&tag_log)[0].1["changedFiles"], 502 serde_json::json!(["a.txt", "src/deep/main.rs"]), 503 "the tag object peels to its commit before the trees are diffed" 504 ); 505} 506 507fn ci_yaml(paths: &str) -> String { 508 format!( 509 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n paths: ['{paths}']\n" 510 ) 511} 512 513#[test] 514fn a_push_wider_than_the_record_prints_the_ssh_command_only_on_a_listed_glob_hit() { 515 let world = world(); 516 commit_file( 517 &world.work, 518 ".tangled/workflows/ci.yml", 519 &ci_yaml("never/**"), 520 "add ci", 521 ); 522 (0..20_000).for_each(|index| { 523 std::fs::write(world.work.join(format!("f{index}.txt")), "x\n").unwrap(); 524 }); 525 git(&world.work, &["add", "-A"]); 526 git(&world.work, &["commit", "-q", "-m", "many"]); 527 push(&world, "main"); 528 let unmatched = oid(&world, "HEAD"); 529 commit_file( 530 &world.work, 531 ".tangled/workflows/ci.yml", 532 &ci_yaml("f1.txt"), 533 "aim ci", 534 ); 535 push(&world, "main"); 536 let matched = oid(&world, "HEAD"); 537 538 let wide_log = log(); 539 let messages = run( 540 &world, 541 &wide_log, 542 &created("main", unmatched), 543 &compile_with_logs("logs.oyster.cafe:3333"), 544 None, 545 ); 546 let listed = events(&wide_log)[0].1["changedFiles"] 547 .as_array() 548 .unwrap() 549 .len(); 550 assert!( 551 (1..20_000).contains(&listed), 552 "the listing stops at the byte budget instead of growing with the push: {listed}" 553 ); 554 assert!( 555 messages.iter().all(|line| !line.contains("ssh -t")), 556 "spindle reads the same truncated listing, rules the globs out, \ 557 and skips this run: {messages:?}" 558 ); 559 560 let messages = run( 561 &world, 562 &log(), 563 &created("main", matched), 564 &compile_with_logs("logs.oyster.cafe:3333"), 565 None, 566 ); 567 assert!( 568 messages.iter().any(|line| line.contains("ssh -t -p 3333")), 569 "spindle sees the same listed path and runs this workflow: {messages:?}" 570 ); 571} 572 573#[test] 574fn a_compiled_workflow_emits_no_pipeline_event() { 575 let world = world(); 576 commit_file( 577 &world.work, 578 ".tangled/workflows/ci.yml", 579 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n", 580 "add ci", 581 ); 582 push(&world, "main"); 583 let head = oid(&world, "HEAD"); 584 585 let log = log(); 586 let applied = created("main", head); 587 run(&world, &log, &applied, &compile(false), None); 588 589 let events = events(&log); 590 assert!(events.iter().all(|(nsid, _)| nsid != "sh.tangled.pipeline")); 591 assert_eq!(events.len(), 1); 592 assert_eq!(events[0].0, "sh.tangled.git.refUpdate"); 593} 594 595#[test] 596fn a_new_non_default_branch_yields_a_pull_request_link() { 597 let world = world(); 598 let log = log(); 599 let head = create_feature(&world); 600 601 let applied = created("feature", head); 602 let messages = run(&world, &log, &applied, &Ci::Skip, Some(&pull())); 603 604 let link = messages 605 .iter() 606 .find(|line| line.contains("/pulls/new")) 607 .expect("pull-request link is offered for new non-default branch"); 608 assert!( 609 link.contains("https://tangled.test/nel.pet/anemone/pulls/new"), 610 "{link}" 611 ); 612 assert!(link.contains("sourceBranch=feature"), "{link}"); 613 assert!(link.contains("targetBranch=main"), "{link}"); 614} 615 616#[test] 617fn no_pull_request_link_for_default_existing_forked_or_rootless_branches() { 618 type Case = (&'static str, fn(&World) -> [RefUpdate; 1]); 619 let cases: &[Case] = &[ 620 ("push to the default branch", |w| create(w, "main")), 621 ("update to an existing branch", |w| { 622 commit_file(&w.work, "a.txt", "one\n", "first"); 623 push(w, "main"); 624 git(&w.work, &["checkout", "-q", "-b", "feature"]); 625 commit_file(&w.work, "c.txt", "three\n", "feature"); 626 push(w, "feature"); 627 let old = oid(w, "HEAD"); 628 commit_file(&w.work, "d.txt", "four\n", "more feature"); 629 push(w, "feature"); 630 [RefUpdate::Update { 631 name: refname("refs/heads/feature"), 632 old, 633 new: oid(w, "HEAD"), 634 }] 635 }), 636 ("new branch on a fork with an origin remote", |w| { 637 let head = create_feature(w); 638 w.repo 639 .set_origin_url(&OriginUrl::new("https://oyster.cafe/did:plc:squid/anemone")) 640 .unwrap(); 641 created("feature", head) 642 }), 643 ("new branch while the default branch is absent", |w| { 644 commit_file(&w.work, "a.txt", "one\n", "first"); 645 git(&w.work, &["checkout", "-q", "-b", "feature"]); 646 commit_file(&w.work, "c.txt", "three\n", "feature work"); 647 push(w, "feature"); 648 created("feature", oid(w, "HEAD")) 649 }), 650 ]; 651 cases.iter().for_each(|(label, build)| { 652 let world = world(); 653 let log = log(); 654 let applied = build(&world); 655 let messages = run(&world, &log, &applied, &Ci::Skip, Some(&pull())); 656 assert!( 657 messages.iter().all(|line| !line.contains("/pulls/new")), 658 "{label} must offer no pull-request link: {messages:?}" 659 ); 660 }); 661} 662 663#[test] 664fn verbose_ci_reports_a_clean_pipeline_and_quiet_ci_stays_silent() { 665 let world = world(); 666 let log = log(); 667 commit_file( 668 &world.work, 669 ".tangled/workflows/ci.yml", 670 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n", 671 "add ci", 672 ); 673 push(&world, "main"); 674 let head = oid(&world, "HEAD"); 675 let applied = created("main", head); 676 677 let verbose = run(&world, &log, &applied, &compile(true), None); 678 assert!( 679 verbose.iter().any(|line| line.contains("no diagnostics")), 680 "verbose ci announces clean compile: {verbose:?}" 681 ); 682 683 let quiet = run(&world, &log, &applied, &compile(false), None); 684 assert!( 685 quiet.iter().all(|line| !line.contains("no diagnostics")), 686 "quiet push says nothing about clean compile: {quiet:?}" 687 ); 688} 689 690#[test] 691fn a_pipeline_compile_error_reaches_the_pusher_even_when_quiet() { 692 let world = world(); 693 let log = log(); 694 commit_file( 695 &world.work, 696 ".tangled/workflows/broken.yml", 697 "engine: : not valid : yaml :\n - [\n", 698 "add broken ci", 699 ); 700 push(&world, "main"); 701 let head = oid(&world, "HEAD"); 702 let applied = created("main", head); 703 704 let messages = run(&world, &log, &applied, &compile(false), None); 705 assert!( 706 messages.iter().any(|line| line.starts_with("error:")), 707 "malformed workflow surfaces compile error to pusher: {messages:?}" 708 ); 709}