This repository has no description
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}