1use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8PathBuf;
17use zeroize::Zeroizing;
18
19use super::secrets;
20use crate::detect::{self, Forge};
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25fn canonical<T: serde::Serialize>(value: &T) -> String {
35 serde_json::to_string(value).unwrap_or_default()
36}
37
38const fn bool_word(value: bool) -> &'static str {
39 if value { "true" } else { "false" }
40}
41
42fn json_list(values: &[String]) -> String {
45 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
46 format!("[{}]", inner.join(", "))
47}
48
49pub(super) fn github_bypass_actors(values: &[String]) -> serde_json::Value {
55 serde_json::Value::Array(
56 values
57 .iter()
58 .filter_map(|value| match value.as_str() {
59 crate::config::LOCAL_GITHUB_BYPASS => Some(serde_json::json!({
60 "actor_id": 5,
61 "actor_type": "RepositoryRole",
62 "bypass_mode": "always",
63 })),
64 _ => None,
65 })
66 .collect(),
67 )
68}
69
70fn compose_rules(
83 protection: &crate::config::Protection,
84 wanted: &[&str],
85 required_check: &str,
86 title_check: &str,
87) -> String {
88 let rules: Vec<serde_json::Value> = protection
89 .owned_trunk_rules
90 .iter()
91 .filter(|rule| wanted.iter().any(|kind| kind == &rule.as_str()))
92 .map(|rule| match rule.as_str() {
93 "pull_request" => serde_json::json!({
94 "type": "pull_request",
95 "parameters": {
96 "required_approving_review_count": protection.required_approving_review_count,
97 "dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
98 "require_code_owner_review": protection.require_code_owner_review,
99 "require_last_push_approval": protection.require_last_push_approval,
100 "required_review_thread_resolution": false,
101 "require_extra_approval_for_unattributed_changes": false,
102 "allowed_merge_methods": protection.allowed_merge_methods,
103 }
104 }),
105 "required_status_checks" => serde_json::json!({
106 "type": "required_status_checks",
107 "parameters": {
108 "do_not_enforce_on_create": true,
109 "strict_required_status_checks_policy":
110 protection.strict_required_status_checks,
111 "required_status_checks": [
112 { "context": required_check },
113 { "context": title_check },
114 ],
115 }
116 }),
117 other => serde_json::json!({ "type": other }),
118 })
119 .collect();
120 serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
123}
124
125fn effective_protection(
147 stated: Option<&crate::config::Protection>,
148 integration: crate::landing::Integration,
149) -> crate::config::Protection {
150 if let Some(stated) = stated {
151 if integration == crate::landing::Integration::Local
152 && crate::config::legacy_local_protection(stated)
153 {
154 let mut migrated = stated.clone();
155 let current = crate::config::local_protection();
156 migrated.bypass_actors = current.bypass_actors;
157 migrated.owned_trunk_rules = current.owned_trunk_rules;
158 migrated.gitlab.push_access_level = current.gitlab.push_access_level;
159 return migrated;
160 }
161 return stated.clone();
162 }
163 if integration == crate::landing::Integration::Local {
164 return crate::config::local_protection();
165 }
166 crate::config::Protection::default()
167}
168
169const PASSTHROUGH: [&str; 11] = [
173 "PATH",
174 "HOME",
175 "XDG_CONFIG_HOME",
176 "GH_TOKEN",
177 "GITHUB_TOKEN",
178 "GH_HOST",
179 "GH_CONFIG_DIR",
180 "GLAB_TOKEN",
181 "GITLAB_TOKEN",
182 "GITLAB_HOST",
183 "GLAB_CONFIG_DIR",
184];
185
186pub use super::secrets::VALUE_VARS as SECRET_VARS;
192
193#[derive(Debug, Clone)]
195pub struct Ctx {
196 pub target: Utf8PathBuf,
198 pub repo: String,
200 pub forge: Option<Forge>,
205 pub declared_forge: Option<String>,
208 pub profile: ProfileSnapshot,
210 pub capabilities: CapabilityRequests,
212 pub host: Option<String>,
214 pub required_check: Option<String>,
216 pub required_workflow: Option<String>,
221 pub cli: PathBuf,
223 pub tech: Option<&'static str>,
225 trunk: String,
228 line_prefix: String,
231 retired_branches: Vec<String>,
234 release_lines: bool,
237 excluded_steps: std::collections::BTreeMap<String, String>,
240 bot_app_id: Option<String>,
243 trunk_ruleset: String,
245 safety_ruleset: String,
248 tag_ruleset: String,
250 lines_ruleset: String,
252 title_check: String,
254 protection: crate::config::Protection,
262 landed: Option<Vec<String>>,
265 integration: crate::landing::Integration,
270}
271
272impl Ctx {
273 pub fn resolve(
282 target: &Utf8PathBuf,
283 repo_flag: Option<&str>,
284 forge_flag: Option<&str>,
285 required_check: Option<&str>,
286 ) -> Result<Self, RkError> {
287 if !target.is_dir() {
288 return Err(RkError::missing(
289 Diagnostic::new(
290 Reason::TargetNotFound,
291 format!("target {target} is not a directory; nothing was run"),
292 )
293 .expected("an existing repository to set up"),
294 ));
295 }
296 let forge_flag = forge_flag
297 .map(|name| {
298 detect::Forge::parse(name).ok_or_else(|| {
299 RkError::Usage(format!(
300 "unknown forge '{name}'; the forges are: github, gitlab"
301 ))
302 })
303 })
304 .transpose()?;
305 let detected = detect::detect(target.as_std_path());
306 let config = crate::config::load(target.as_std_path())?;
307 let record = crate::landing::manifest::load(target)?;
308 let resolved = crate::profile::Params::resolve(
312 target,
313 &crate::profile::Inputs {
314 forge: forge_flag.map(Forge::as_str),
315 repo: repo_flag,
316 ..crate::profile::Inputs::default()
317 },
318 config.as_ref(),
319 record.as_ref(),
320 crate::profile::Purpose::Preview,
321 )?;
322 let declared_forge = resolved.forge().map(str::to_owned);
323 let forge = declared_forge.as_deref().and_then(Forge::parse);
324 let repo = resolved.repo().to_owned();
325 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
326 String::new()
327 } else {
328 repo
329 };
330 let cli = PathBuf::new();
334 let answers = config
335 .as_ref()
336 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
337 let required_check = required_check.map(str::to_owned).or_else(|| {
341 Some(answers.required_check.clone())
342 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
343 });
344 let required_workflow = Some(answers.required_workflow.clone())
345 .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
346 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
347 let trunk = resolved.trunk().to_owned();
348 let integration = record
353 .as_ref()
354 .map_or_else(crate::landing::manifest::integration_forge, |held| {
355 held.git.integration
356 });
357 let landed = record.as_ref().map(|held| {
358 held.files
359 .iter()
360 .map(|file| file.destination.clone())
361 .collect()
362 });
363 let stated = config.as_ref().map(|held| &held.protection);
364 let protection = effective_protection(stated, integration);
365 Ok(Self {
366 target: target.clone(),
367 repo,
368 forge,
369 host: detected.host,
370 required_check,
371 required_workflow,
372 cli,
373 tech: resolved.driver().and_then(|driver| {
374 ["rust", "python", "bash"]
375 .into_iter()
376 .find(|known| *known == driver)
377 }),
378 trunk_ruleset: protection.trunk_ruleset(&trunk),
379 safety_ruleset: protection.safety_ruleset(&trunk),
380 tag_ruleset: protection.tag_ruleset.clone(),
381 lines_ruleset: protection.lines_ruleset.clone(),
382 title_check: protection.title_check.clone(),
383 protection,
384 integration,
385 landed,
386 trunk,
387 line_prefix: resolved.line_prefix().to_owned(),
388 profile: resolved.profile().clone(),
389 capabilities: resolved.capabilities().clone(),
390 declared_forge,
391 retired_branches: answers.retired_branches,
392 release_lines: answers.release_lines,
393 excluded_steps: answers.excluded_steps,
394 bot_app_id,
395 })
396 }
397
398 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
413 let Some(forge) = self.forge else {
414 return Ok(());
415 };
416 let calls = steps.iter().any(|step| {
417 step.forge_cli.contains(&forge) && crate::setup::report::stance(self, step).acts()
418 });
419 if calls && self.cli.as_os_str().is_empty() {
420 self.cli = resolve_cli(forge)?;
421 }
422 Ok(())
423 }
424
425 #[doc(hidden)]
430 #[must_use]
431 pub fn for_tests(
432 target: Utf8PathBuf,
433 repo: String,
434 forge: Forge,
435 cli: PathBuf,
436 tech: Option<&'static str>,
437 ) -> Self {
438 let defaults = crate::config::Protection::default();
439 Self {
440 integration: crate::landing::Integration::Forge,
443 landed: None,
444 target,
445 repo,
446 forge: Some(forge),
447 declared_forge: Some(forge.as_str().to_owned()),
448 profile: ProfileSnapshot {
449 technologies: tech.into_iter().map(str::to_owned).collect(),
450 forge: Some(forge.as_str().to_owned()),
451 release: crate::profile::ReleaseIntent {
452 mode: ReleaseMode::Automatic,
453 driver: tech.map(str::to_owned),
454 style: Some(crate::landing::Style::Trunk),
455 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
456 },
457 },
458 capabilities: CapabilityRequests {
459 nix_packaging: false,
460 reporting_policy: true,
461 scorecard: false,
462 code_scanning: None,
463 },
464 host: None,
465 required_check: None,
466 required_workflow: None,
467 cli,
468 tech,
469 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
470 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
471 retired_branches: crate::config::Setup::default().retired_branches,
472 release_lines: false,
473 excluded_steps: std::collections::BTreeMap::new(),
474 bot_app_id: None,
475 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
476 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
477 tag_ruleset: defaults.tag_ruleset.clone(),
478 lines_ruleset: defaults.lines_ruleset.clone(),
479 title_check: defaults.title_check.clone(),
480 protection: defaults,
481 }
482 }
483
484 #[must_use]
487 pub fn landed_destinations(&self) -> Option<&[String]> {
488 self.landed.as_deref()
489 }
490
491 #[must_use]
493 pub const fn has_adapter(&self) -> bool {
494 self.forge.is_some()
495 }
496
497 pub fn adapter(&self) -> Result<Forge, RkError> {
505 self.forge.ok_or_else(|| {
506 let named = self.declared_forge.as_deref();
507 let message = named.map_or_else(
508 || "the profile names no forge, and this operation acts on one".to_owned(),
509 |name| {
510 format!(
511 "the profile names the forge {name}, which this release has no adapter for"
512 )
513 },
514 );
515 RkError::refusal(
516 Diagnostic::new(Reason::PrerequisiteUnmet, message)
517 .expected("a profile naming github or gitlab")
518 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
519 .target_state("unchanged"),
520 )
521 })
522 }
523
524 #[must_use]
526 pub const fn automatic_release(&self) -> bool {
527 matches!(self.profile.release.mode, ReleaseMode::Automatic)
528 }
529
530 #[must_use]
532 pub fn driver(&self) -> Option<&str> {
533 self.profile.release.driver.as_deref()
534 }
535
536 #[must_use]
538 pub fn declared_forge(&self) -> Option<&str> {
539 self.declared_forge.as_deref()
540 }
541
542 #[must_use]
544 pub const fn reporting_policy(&self) -> bool {
545 self.capabilities.reporting_policy
546 }
547
548 #[must_use]
550 pub fn trunk(&self) -> &str {
551 &self.trunk
552 }
553
554 #[must_use]
556 pub fn line_prefix(&self) -> &str {
557 &self.line_prefix
558 }
559
560 #[must_use]
562 pub fn retired_branches(&self) -> &[String] {
563 &self.retired_branches
564 }
565
566 #[must_use]
568 pub const fn release_lines(&self) -> bool {
569 self.release_lines
570 }
571
572 #[must_use]
576 pub fn excluded(&self, step: &str) -> Option<&str> {
577 self.excluded_steps.get(step).map(String::as_str)
578 }
579
580 #[must_use]
582 pub fn excluded_count(&self) -> usize {
583 self.excluded_steps.len()
584 }
585
586 #[must_use]
588 pub fn bot_app_id(&self) -> Option<&str> {
589 self.bot_app_id.as_deref()
590 }
591
592 #[must_use]
594 pub fn trunk_ruleset(&self) -> &str {
595 &self.trunk_ruleset
596 }
597
598 #[must_use]
600 pub fn safety_ruleset(&self) -> &str {
601 &self.safety_ruleset
602 }
603
604 #[must_use]
606 pub fn tag_ruleset(&self) -> &str {
607 &self.tag_ruleset
608 }
609
610 #[must_use]
612 pub fn lines_ruleset(&self) -> &str {
613 &self.lines_ruleset
614 }
615
616 #[must_use]
618 pub fn title_check(&self) -> &str {
619 &self.title_check
620 }
621
622 #[must_use]
624 pub const fn integration(&self) -> crate::landing::Integration {
625 self.integration
626 }
627
628 fn trunk_rules(&self) -> String {
631 compose_rules(
632 &self.protection,
633 &crate::config::REQUEST_RULES,
634 self.required_check.as_deref().unwrap_or_default(),
635 &self.title_check,
636 )
637 }
638
639 fn safety_rules(&self) -> String {
641 compose_rules(
642 &self.protection,
643 &crate::config::SAFETY_RULES,
644 self.required_check.as_deref().unwrap_or_default(),
645 &self.title_check,
646 )
647 }
648
649 #[must_use]
657 pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
658 let mut fields = std::collections::BTreeMap::new();
659 let mut put = |key: &str, value: String| {
660 fields.insert(key.to_owned(), value);
661 };
662 put("forge", self.declared_forge.clone().unwrap_or_default());
663 put("host", self.host.clone().unwrap_or_default());
664 put("repo", self.repo.clone());
665 put("profile", canonical(&self.profile));
666 put("capabilities", canonical(&self.capabilities));
667 put("trunk", self.trunk.clone());
668 put("line_prefix", self.line_prefix.clone());
669 put("retired_branches", canonical(&self.retired_branches));
670 put("release_lines", bool_word(self.release_lines).to_owned());
671 put("integration", canonical(&self.integration));
672 put(
673 "required_check",
674 self.required_check.clone().unwrap_or_default(),
675 );
676 put(
677 "required_workflow",
678 self.required_workflow.clone().unwrap_or_default(),
679 );
680 put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
681 put("trunk_ruleset", self.trunk_ruleset.clone());
682 put("safety_ruleset", self.safety_ruleset.clone());
683 put("tag_ruleset", self.tag_ruleset.clone());
684 put("lines_ruleset", self.lines_ruleset.clone());
685 put("title_check", self.title_check.clone());
686 put("protection", canonical(&self.protection));
687 fields
688 }
689
690 #[must_use]
692 pub const fn protection(&self) -> &crate::config::Protection {
693 &self.protection
694 }
695
696 #[must_use]
699 pub fn self_hosted_gitlab(&self) -> bool {
700 self.forge == Some(Forge::Gitlab)
701 && self
702 .host
703 .as_deref()
704 .is_some_and(|host| host != "gitlab.com")
705 }
706
707 #[must_use]
710 #[allow(
711 clippy::too_many_lines,
712 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
713 )]
714 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
715 let mut env: Vec<(OsString, OsString)> = vec![
716 (
717 "RK_FORGE".into(),
718 self.forge.map_or("", Forge::as_str).into(),
719 ),
720 ("RK_REPO".into(), self.repo.clone().into()),
721 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
722 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
723 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
724 (
725 "RK_SAFETY_RULESET".into(),
726 self.safety_ruleset.clone().into(),
727 ),
728 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
729 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
730 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
731 (
735 "RK_TAG_PATTERN".into(),
736 self.protection.tag_pattern.clone().into(),
737 ),
738 (
739 "RK_REVIEW_COUNT".into(),
740 self.protection
741 .required_approving_review_count
742 .to_string()
743 .into(),
744 ),
745 (
746 "RK_DISMISS_STALE_REVIEWS".into(),
747 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
748 ),
749 (
750 "RK_CODE_OWNER_REVIEW".into(),
751 bool_word(self.protection.require_code_owner_review).into(),
752 ),
753 (
754 "RK_LAST_PUSH_APPROVAL".into(),
755 bool_word(self.protection.require_last_push_approval).into(),
756 ),
757 (
758 "RK_MERGE_METHODS".into(),
759 json_list(&self.protection.allowed_merge_methods).into(),
760 ),
761 (
762 "RK_STRICT_CHECKS".into(),
763 bool_word(self.protection.strict_required_status_checks).into(),
764 ),
765 (
766 "RK_BYPASS_ACTORS".into(),
767 github_bypass_actors(&self.protection.bypass_actors)
768 .to_string()
769 .into(),
770 ),
771 (
772 "RK_SQUASH_TITLE_SOURCE".into(),
773 self.protection.github.squash_title_source.clone().into(),
774 ),
775 (
776 "RK_SQUASH_BODY_SOURCE".into(),
777 self.protection.github.squash_body_source.clone().into(),
778 ),
779 (
780 "RK_GITLAB_MERGE_METHOD".into(),
781 self.protection.gitlab.merge_method.clone().into(),
782 ),
783 (
784 "RK_GITLAB_SQUASH_OPTION".into(),
785 self.protection.gitlab.squash_option.clone().into(),
786 ),
787 (
788 "RK_GITLAB_SQUASH_TEMPLATE".into(),
789 self.protection.gitlab.squash_commit_template.clone().into(),
790 ),
791 (
792 "RK_GITLAB_PUSH_LEVEL".into(),
793 self.protection.gitlab.push_access_level.to_string().into(),
794 ),
795 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
803 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
804 (
805 "RK_GITLAB_MERGE_LEVEL".into(),
806 self.protection.gitlab.merge_access_level.to_string().into(),
807 ),
808 ("GH_PAGER".into(), "".into()),
809 ("GLAB_PAGER".into(), "".into()),
810 ];
811 if let Some(check) = &self.required_check
812 && self.forge == Some(Forge::Github)
813 && matches!(step, "protect-trunk" | "protections-check")
814 {
815 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
816 }
817 for name in PASSTHROUGH {
818 if let Some(value) = std::env::var_os(name) {
819 env.push((name.into(), value));
820 }
821 }
822 if let Some(dir) = self.cli_override_dir() {
826 let mut paths: Vec<PathBuf> = vec![dir];
827 if let Some(existing) = std::env::var_os("PATH") {
828 paths.extend(std::env::split_paths(&existing));
829 }
830 if let Ok(joined) = std::env::join_paths(paths) {
831 env.retain(|(name, _)| name != "PATH");
832 env.push(("PATH".into(), joined));
833 }
834 }
835 if step == "bot-secrets" {
836 for name in SECRET_VARS {
837 if let Some(value) = secrets::value_of(name) {
838 env.push((name.into(), value));
839 }
840 }
841 }
842 env
843 }
844
845 fn cli_override_dir(&self) -> Option<PathBuf> {
847 let overridden = std::env::var_os(match self.forge? {
848 Forge::Github => "RK_GH_BIN",
849 Forge::Gitlab => "RK_GLAB_BIN",
850 })?;
851 Path::new(&overridden).parent().map(Path::to_path_buf)
852 }
853
854 #[must_use]
862 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
863 SECRET_VARS
864 .iter()
865 .filter_map(|name| secrets::value_of(name))
866 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
867 .collect()
868 }
869}
870
871pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
881 let override_var = match forge {
882 Forge::Github => "RK_GH_BIN",
883 Forge::Gitlab => "RK_GLAB_BIN",
884 };
885 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
886 let path = PathBuf::from(&overridden);
887 if !path.is_file() {
888 return Err(RkError::refusal(
889 Diagnostic::new(
890 Reason::PrerequisiteUnmet,
891 format!(
892 "{override_var} names {}, which does not exist",
893 path.display()
894 ),
895 )
896 .expected("the override to name the forge CLI binary"),
897 ));
898 }
899 if path.file_name().is_none_or(|name| name != forge.cli()) {
904 return Err(RkError::refusal(
905 Diagnostic::new(
906 Reason::PrerequisiteUnmet,
907 format!(
908 "{override_var} must name a binary called {}, and {} is not one",
909 forge.cli(),
910 path.display()
911 ),
912 )
913 .expected(format!(
914 "an override whose file name is {}, so scripts and observations run one binary",
915 forge.cli()
916 )),
917 ));
918 }
919 return Ok(path);
920 }
921 let name = forge.cli();
922 let found = std::env::var_os("PATH").and_then(|path| {
923 std::env::split_paths(&path)
924 .map(|dir| dir.join(name))
925 .find(|candidate| candidate.is_file())
926 });
927 found.ok_or_else(|| {
928 RkError::refusal(
929 Diagnostic::new(
930 Reason::PrerequisiteUnmet,
931 format!(
932 "{name} is not on PATH, and a step this run acts on calls it on {}",
933 forge.as_str()
934 ),
935 )
936 .expected(format!("the {name} CLI installed and authenticated"))
937 .action(format!("install {name}, then run {name} auth login")),
938 )
939 })
940}
941
942#[cfg(test)]
943mod tests {
944 #[test]
951 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
952 let kinds = |text: &str| -> Vec<String> {
953 let parsed: Vec<serde_json::Value> =
954 serde_json::from_str(text).expect("the rules parse");
955 parsed
956 .iter()
957 .filter_map(|rule| rule["type"].as_str())
958 .map(str::to_owned)
959 .collect()
960 };
961 let mut policy = crate::config::Protection::default();
962 let request =
963 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
964 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
965 let safety =
966 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
967 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
968
969 let parsed: Vec<serde_json::Value> =
970 serde_json::from_str(&request).expect("the rules parse");
971 let checks = parsed
972 .iter()
973 .find(|rule| rule["type"] == "required_status_checks")
974 .expect("the check rule");
975 assert_eq!(
976 checks["parameters"]["required_status_checks"],
977 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
978 );
979
980 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
983 assert_eq!(
984 kinds(&super::compose_rules(
985 &policy,
986 &crate::config::REQUEST_RULES,
987 "gate",
988 "pr-title"
989 )),
990 ["pull_request", "required_status_checks"]
991 );
992 assert_eq!(
993 kinds(&super::compose_rules(
994 &policy,
995 &crate::config::SAFETY_RULES,
996 "gate",
997 "pr-title"
998 )),
999 ["deletion", "non_fast_forward"]
1000 );
1001 }
1002
1003 #[test]
1016 fn the_effective_policy_follows_the_recorded_authority() {
1017 use crate::landing::Integration;
1018 let silent = super::effective_protection(None, Integration::Forge);
1019 assert!(
1020 silent
1021 .owned_trunk_rules
1022 .contains(&"pull_request".to_owned())
1023 );
1024 assert_eq!(silent.gitlab.push_access_level, 0);
1025
1026 let silent = super::effective_protection(None, Integration::Local);
1027 assert_eq!(
1028 silent.owned_trunk_rules,
1029 crate::config::Protection::default().owned_trunk_rules,
1030 "local integration retains the release request's atomic check"
1031 );
1032 assert_eq!(
1033 silent.bypass_actors,
1034 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1035 );
1036 assert_eq!(
1037 silent.gitlab.push_access_level, 40,
1038 "zero would close the trunk to the push this mode ends in"
1039 );
1040
1041 let mut stated = crate::config::Protection::default();
1042 stated.gitlab.push_access_level = 0;
1043 stated.owned_trunk_rules = vec!["deletion".into()];
1044 let held = super::effective_protection(Some(&stated), Integration::Local);
1045 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1046 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1047
1048 let legacy = crate::config::Protection {
1049 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1050 gitlab: crate::config::Gitlab {
1051 push_access_level: 40,
1052 ..crate::config::Gitlab::default()
1053 },
1054 ..crate::config::Protection::default()
1055 };
1056 let migrated = super::effective_protection(Some(&legacy), Integration::Local);
1057 assert_eq!(
1058 migrated,
1059 crate::config::local_protection(),
1060 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1061 );
1062 }
1063}