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 workflow_guards: Some(true),
464 },
465 host: None,
466 required_check: None,
467 required_workflow: None,
468 cli,
469 tech,
470 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
471 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
472 retired_branches: crate::config::Setup::default().retired_branches,
473 release_lines: false,
474 excluded_steps: std::collections::BTreeMap::new(),
475 bot_app_id: None,
476 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
477 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
478 tag_ruleset: defaults.tag_ruleset.clone(),
479 lines_ruleset: defaults.lines_ruleset.clone(),
480 title_check: defaults.title_check.clone(),
481 protection: defaults,
482 }
483 }
484
485 #[must_use]
488 pub fn landed_destinations(&self) -> Option<&[String]> {
489 self.landed.as_deref()
490 }
491
492 #[must_use]
494 pub const fn has_adapter(&self) -> bool {
495 self.forge.is_some()
496 }
497
498 pub fn adapter(&self) -> Result<Forge, RkError> {
506 self.forge.ok_or_else(|| {
507 let named = self.declared_forge.as_deref();
508 let message = named.map_or_else(
509 || "the profile names no forge, and this operation acts on one".to_owned(),
510 |name| {
511 format!(
512 "the profile names the forge {name}, which this release has no adapter for"
513 )
514 },
515 );
516 RkError::refusal(
517 Diagnostic::new(Reason::PrerequisiteUnmet, message)
518 .expected("a profile naming github or gitlab")
519 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
520 .target_state("unchanged"),
521 )
522 })
523 }
524
525 #[must_use]
527 pub const fn automatic_release(&self) -> bool {
528 matches!(self.profile.release.mode, ReleaseMode::Automatic)
529 }
530
531 #[must_use]
533 pub fn driver(&self) -> Option<&str> {
534 self.profile.release.driver.as_deref()
535 }
536
537 #[must_use]
539 pub fn declared_forge(&self) -> Option<&str> {
540 self.declared_forge.as_deref()
541 }
542
543 #[must_use]
545 pub const fn reporting_policy(&self) -> bool {
546 self.capabilities.reporting_policy
547 }
548
549 #[must_use]
551 pub fn trunk(&self) -> &str {
552 &self.trunk
553 }
554
555 #[must_use]
557 pub fn line_prefix(&self) -> &str {
558 &self.line_prefix
559 }
560
561 #[must_use]
563 pub fn retired_branches(&self) -> &[String] {
564 &self.retired_branches
565 }
566
567 #[must_use]
569 pub const fn release_lines(&self) -> bool {
570 self.release_lines
571 }
572
573 #[must_use]
577 pub fn excluded(&self, step: &str) -> Option<&str> {
578 self.excluded_steps.get(step).map(String::as_str)
579 }
580
581 #[must_use]
583 pub fn excluded_count(&self) -> usize {
584 self.excluded_steps.len()
585 }
586
587 #[must_use]
589 pub fn bot_app_id(&self) -> Option<&str> {
590 self.bot_app_id.as_deref()
591 }
592
593 #[must_use]
595 pub fn trunk_ruleset(&self) -> &str {
596 &self.trunk_ruleset
597 }
598
599 #[must_use]
601 pub fn safety_ruleset(&self) -> &str {
602 &self.safety_ruleset
603 }
604
605 #[must_use]
607 pub fn tag_ruleset(&self) -> &str {
608 &self.tag_ruleset
609 }
610
611 #[must_use]
613 pub fn lines_ruleset(&self) -> &str {
614 &self.lines_ruleset
615 }
616
617 #[must_use]
619 pub fn title_check(&self) -> &str {
620 &self.title_check
621 }
622
623 #[must_use]
625 pub const fn integration(&self) -> crate::landing::Integration {
626 self.integration
627 }
628
629 fn trunk_rules(&self) -> String {
632 compose_rules(
633 &self.protection,
634 &crate::config::REQUEST_RULES,
635 self.required_check.as_deref().unwrap_or_default(),
636 &self.title_check,
637 )
638 }
639
640 fn safety_rules(&self) -> String {
642 compose_rules(
643 &self.protection,
644 &crate::config::SAFETY_RULES,
645 self.required_check.as_deref().unwrap_or_default(),
646 &self.title_check,
647 )
648 }
649
650 #[must_use]
658 pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
659 let mut fields = std::collections::BTreeMap::new();
660 let mut put = |key: &str, value: String| {
661 fields.insert(key.to_owned(), value);
662 };
663 put("forge", self.declared_forge.clone().unwrap_or_default());
664 put("host", self.host.clone().unwrap_or_default());
665 put("repo", self.repo.clone());
666 put("profile", canonical(&self.profile));
667 put("capabilities", canonical(&self.capabilities));
668 put("trunk", self.trunk.clone());
669 put("line_prefix", self.line_prefix.clone());
670 put("retired_branches", canonical(&self.retired_branches));
671 put("release_lines", bool_word(self.release_lines).to_owned());
672 put("integration", canonical(&self.integration));
673 put(
674 "required_check",
675 self.required_check.clone().unwrap_or_default(),
676 );
677 put(
678 "required_workflow",
679 self.required_workflow.clone().unwrap_or_default(),
680 );
681 put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
682 put("trunk_ruleset", self.trunk_ruleset.clone());
683 put("safety_ruleset", self.safety_ruleset.clone());
684 put("tag_ruleset", self.tag_ruleset.clone());
685 put("lines_ruleset", self.lines_ruleset.clone());
686 put("title_check", self.title_check.clone());
687 put("protection", canonical(&self.protection));
688 fields
689 }
690
691 #[must_use]
693 pub const fn protection(&self) -> &crate::config::Protection {
694 &self.protection
695 }
696
697 #[must_use]
700 pub fn self_hosted_gitlab(&self) -> bool {
701 self.forge == Some(Forge::Gitlab)
702 && self
703 .host
704 .as_deref()
705 .is_some_and(|host| host != "gitlab.com")
706 }
707
708 #[must_use]
711 #[allow(
712 clippy::too_many_lines,
713 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
714 )]
715 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
716 let mut env: Vec<(OsString, OsString)> = vec![
717 (
718 "RK_FORGE".into(),
719 self.forge.map_or("", Forge::as_str).into(),
720 ),
721 ("RK_REPO".into(), self.repo.clone().into()),
722 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
723 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
724 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
725 (
726 "RK_SAFETY_RULESET".into(),
727 self.safety_ruleset.clone().into(),
728 ),
729 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
730 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
731 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
732 (
736 "RK_TAG_PATTERN".into(),
737 self.protection.tag_pattern.clone().into(),
738 ),
739 (
740 "RK_REVIEW_COUNT".into(),
741 self.protection
742 .required_approving_review_count
743 .to_string()
744 .into(),
745 ),
746 (
747 "RK_DISMISS_STALE_REVIEWS".into(),
748 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
749 ),
750 (
751 "RK_CODE_OWNER_REVIEW".into(),
752 bool_word(self.protection.require_code_owner_review).into(),
753 ),
754 (
755 "RK_LAST_PUSH_APPROVAL".into(),
756 bool_word(self.protection.require_last_push_approval).into(),
757 ),
758 (
759 "RK_MERGE_METHODS".into(),
760 json_list(&self.protection.allowed_merge_methods).into(),
761 ),
762 (
763 "RK_STRICT_CHECKS".into(),
764 bool_word(self.protection.strict_required_status_checks).into(),
765 ),
766 (
767 "RK_BYPASS_ACTORS".into(),
768 github_bypass_actors(&self.protection.bypass_actors)
769 .to_string()
770 .into(),
771 ),
772 (
773 "RK_SQUASH_TITLE_SOURCE".into(),
774 self.protection.github.squash_title_source.clone().into(),
775 ),
776 (
777 "RK_SQUASH_BODY_SOURCE".into(),
778 self.protection.github.squash_body_source.clone().into(),
779 ),
780 (
781 "RK_GITLAB_MERGE_METHOD".into(),
782 self.protection.gitlab.merge_method.clone().into(),
783 ),
784 (
785 "RK_GITLAB_SQUASH_OPTION".into(),
786 self.protection.gitlab.squash_option.clone().into(),
787 ),
788 (
789 "RK_GITLAB_SQUASH_TEMPLATE".into(),
790 self.protection.gitlab.squash_commit_template.clone().into(),
791 ),
792 (
793 "RK_GITLAB_PUSH_LEVEL".into(),
794 self.protection.gitlab.push_access_level.to_string().into(),
795 ),
796 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
804 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
805 (
806 "RK_GITLAB_MERGE_LEVEL".into(),
807 self.protection.gitlab.merge_access_level.to_string().into(),
808 ),
809 ("GH_PAGER".into(), "".into()),
810 ("GLAB_PAGER".into(), "".into()),
811 ];
812 if let Some(check) = &self.required_check
813 && self.forge == Some(Forge::Github)
814 && matches!(step, "protect-trunk" | "protections-check")
815 {
816 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
817 }
818 for name in PASSTHROUGH {
819 if let Some(value) = std::env::var_os(name) {
820 env.push((name.into(), value));
821 }
822 }
823 if let Some(dir) = self.cli_override_dir() {
827 let mut paths: Vec<PathBuf> = vec![dir];
828 if let Some(existing) = std::env::var_os("PATH") {
829 paths.extend(std::env::split_paths(&existing));
830 }
831 if let Ok(joined) = std::env::join_paths(paths) {
832 env.retain(|(name, _)| name != "PATH");
833 env.push(("PATH".into(), joined));
834 }
835 }
836 if step == "bot-secrets" {
837 for name in SECRET_VARS {
838 if let Some(value) = secrets::value_of(name) {
839 env.push((name.into(), value));
840 }
841 }
842 }
843 env
844 }
845
846 fn cli_override_dir(&self) -> Option<PathBuf> {
848 let overridden = std::env::var_os(match self.forge? {
849 Forge::Github => "RK_GH_BIN",
850 Forge::Gitlab => "RK_GLAB_BIN",
851 })?;
852 Path::new(&overridden).parent().map(Path::to_path_buf)
853 }
854
855 #[must_use]
863 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
864 SECRET_VARS
865 .iter()
866 .filter_map(|name| secrets::value_of(name))
867 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
868 .collect()
869 }
870}
871
872pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
882 let override_var = match forge {
883 Forge::Github => "RK_GH_BIN",
884 Forge::Gitlab => "RK_GLAB_BIN",
885 };
886 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
887 let path = PathBuf::from(&overridden);
888 if !path.is_file() {
889 return Err(RkError::refusal(
890 Diagnostic::new(
891 Reason::PrerequisiteUnmet,
892 format!(
893 "{override_var} names {}, which does not exist",
894 path.display()
895 ),
896 )
897 .expected("the override to name the forge CLI binary"),
898 ));
899 }
900 if path.file_name().is_none_or(|name| name != forge.cli()) {
905 return Err(RkError::refusal(
906 Diagnostic::new(
907 Reason::PrerequisiteUnmet,
908 format!(
909 "{override_var} must name a binary called {}, and {} is not one",
910 forge.cli(),
911 path.display()
912 ),
913 )
914 .expected(format!(
915 "an override whose file name is {}, so scripts and observations run one binary",
916 forge.cli()
917 )),
918 ));
919 }
920 return Ok(path);
921 }
922 let name = forge.cli();
923 let found = std::env::var_os("PATH").and_then(|path| {
924 std::env::split_paths(&path)
925 .map(|dir| dir.join(name))
926 .find(|candidate| candidate.is_file())
927 });
928 found.ok_or_else(|| {
929 RkError::refusal(
930 Diagnostic::new(
931 Reason::PrerequisiteUnmet,
932 format!(
933 "{name} is not on PATH, and a step this run acts on calls it on {}",
934 forge.as_str()
935 ),
936 )
937 .expected(format!("the {name} CLI installed and authenticated"))
938 .action(format!("install {name}, then run {name} auth login")),
939 )
940 })
941}
942
943#[cfg(test)]
944mod tests {
945 #[test]
952 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
953 let kinds = |text: &str| -> Vec<String> {
954 let parsed: Vec<serde_json::Value> =
955 serde_json::from_str(text).expect("the rules parse");
956 parsed
957 .iter()
958 .filter_map(|rule| rule["type"].as_str())
959 .map(str::to_owned)
960 .collect()
961 };
962 let mut policy = crate::config::Protection::default();
963 let request =
964 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
965 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
966 let safety =
967 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
968 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
969
970 let parsed: Vec<serde_json::Value> =
971 serde_json::from_str(&request).expect("the rules parse");
972 let checks = parsed
973 .iter()
974 .find(|rule| rule["type"] == "required_status_checks")
975 .expect("the check rule");
976 assert_eq!(
977 checks["parameters"]["required_status_checks"],
978 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
979 );
980
981 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
984 assert_eq!(
985 kinds(&super::compose_rules(
986 &policy,
987 &crate::config::REQUEST_RULES,
988 "gate",
989 "pr-title"
990 )),
991 ["pull_request", "required_status_checks"]
992 );
993 assert_eq!(
994 kinds(&super::compose_rules(
995 &policy,
996 &crate::config::SAFETY_RULES,
997 "gate",
998 "pr-title"
999 )),
1000 ["deletion", "non_fast_forward"]
1001 );
1002 }
1003
1004 #[test]
1017 fn the_effective_policy_follows_the_recorded_authority() {
1018 use crate::landing::Integration;
1019 let silent = super::effective_protection(None, Integration::Forge);
1020 assert!(
1021 silent
1022 .owned_trunk_rules
1023 .contains(&"pull_request".to_owned())
1024 );
1025 assert_eq!(silent.gitlab.push_access_level, 0);
1026
1027 let silent = super::effective_protection(None, Integration::Local);
1028 assert_eq!(
1029 silent.owned_trunk_rules,
1030 crate::config::Protection::default().owned_trunk_rules,
1031 "local integration retains the release request's atomic check"
1032 );
1033 assert_eq!(
1034 silent.bypass_actors,
1035 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1036 );
1037 assert_eq!(
1038 silent.gitlab.push_access_level, 40,
1039 "zero would close the trunk to the push this mode ends in"
1040 );
1041
1042 let mut stated = crate::config::Protection::default();
1043 stated.gitlab.push_access_level = 0;
1044 stated.owned_trunk_rules = vec!["deletion".into()];
1045 let held = super::effective_protection(Some(&stated), Integration::Local);
1046 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1047 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1048
1049 let legacy = crate::config::Protection {
1050 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1051 gitlab: crate::config::Gitlab {
1052 push_access_level: 40,
1053 ..crate::config::Gitlab::default()
1054 },
1055 ..crate::config::Protection::default()
1056 };
1057 let migrated = super::effective_protection(Some(&legacy), Integration::Local);
1058 assert_eq!(
1059 migrated,
1060 crate::config::local_protection(),
1061 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1062 );
1063 }
1064}