ephemeris_forge_forgejo/reconcile/
branch_rules.rs1use ephemeris_forge::RepoRef;
2
3use super::Client;
4use crate::api::{Api, ForgeError, request};
5use crate::operation::branch_rules::Operation;
6use crate::{BranchRule, BranchRules, Live};
7
8impl<A: Api> Client<A> {
9 pub(super) async fn read_branch_rules(
10 &self,
11 repository: &RepoRef,
12 config: Option<&BranchRules>,
13 live: &mut Live,
14 ) -> Result<(), ForgeError> {
15 if config.is_some() {
16 live.branch_rules = self.api.branch_protections(repository).await?;
17 }
18 Ok(())
19 }
20
21 pub(super) async fn apply_branch_rules(
22 &self,
23 repository: &RepoRef,
24 operation: &Operation,
25 ) -> Result<(), ForgeError> {
26 match operation {
27 Operation::Create { name, rule } => {
28 let body = request::CreateBranchProtectionOption::new(name, rule);
29 match self.api.create_branch_protection(repository, &body).await {
30 Ok(()) => Ok(()),
31 Err(ForgeError::RuleExists { name: _ }) => {
32 self.recover_existing_rule(repository, name, rule).await
33 }
34 Err(error) => Err(error),
35 }
36 }
37 Operation::Patch { name, edit } => {
38 self.api
39 .patch_branch_protection(repository, name, edit)
40 .await
41 }
42 Operation::Delete { name } => self.api.delete_branch_protection(repository, name).await,
43 }
44 }
45
46 async fn recover_existing_rule(
47 &self,
48 repository: &RepoRef,
49 name: &str,
50 rule: &BranchRule,
51 ) -> Result<(), ForgeError> {
52 tracing::info!(rule = %name, "branch protection appeared during create; patching it");
53 let Some(existing) = self.api.branch_protection(repository, name).await? else {
54 return Err(ForgeError::RuleDisappeared {
55 name: name.to_owned(),
56 });
57 };
58 let (body, _) = request::EditBranchProtectionOption::between(rule, &existing);
59 if body.is_empty() {
60 Ok(())
61 } else {
62 self.api
63 .patch_branch_protection(repository, name, &body)
64 .await
65 }
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use ephemeris_forge::{Backend as _, RepoRef};
72 use ephemeris_loopback::{Response, Server};
73 use ephemeris_token::Token;
74
75 use super::Client;
76 use crate::api::HttpApi;
77 use crate::api::model::BranchProtection;
78 use crate::api::request::EditBranchProtectionOption;
79 use crate::operation::Operation;
80 use crate::operation::branch_rules::Operation as Rule;
81 use crate::{BranchRule, InvalidRuleName, PushAccess, RuleName};
82
83 fn rule() -> BranchRule {
84 BranchRule {
85 apply_to_admins: true,
86 approvals_whitelist_teams: Vec::new(),
87 approvals_whitelist_username: Vec::new(),
88 block_on_official_review_requests: false,
89 block_on_outdated_branch: false,
90 block_on_rejected_reviews: false,
91 dismiss_stale_approvals: false,
92 enable_approvals_whitelist: false,
93 enable_merge_whitelist: false,
94 enable_status_check: true,
95 ignore_stale_approvals: false,
96 merge_whitelist_teams: Vec::new(),
97 merge_whitelist_usernames: Vec::new(),
98 protected_file_patterns: Vec::new(),
99 push: PushAccess::Disabled,
100 push_whitelist_deploy_keys: false,
101 push_whitelist_teams: Vec::new(),
102 push_whitelist_usernames: Vec::new(),
103 require_signed_commits: false,
104 required_approvals: 2,
105 status_check_contexts: vec!["b".to_owned(), "a".to_owned()],
106 unprotected_file_patterns: Vec::new(),
107 }
108 }
109
110 fn target() -> RepoRef {
111 "acme/demo".parse().unwrap()
112 }
113
114 #[test]
115 fn a_create_race_recovers_with_only_the_differing_fields() {
116 let desired = rule();
117 let existing = BranchProtection {
118 apply_to_admins: Some(false),
119 enable_status_check: Some(true),
120 required_approvals: Some(2),
121 status_check_contexts: Some(vec!["a".to_owned(), "b".to_owned()]),
122 ..BranchProtection::default()
123 };
124 let (patch, _) = EditBranchProtectionOption::between(&desired, &existing);
125 assert_eq!(patch.apply_to_admins, Some(true));
126 assert_eq!(patch.enable_status_check, None);
127 assert_eq!(patch.required_approvals, None);
128 assert_eq!(patch.status_check_contexts, None);
129 }
130
131 #[test]
132 fn a_push_access_change_carries_both_flags() {
133 let mut desired = rule();
134 desired.push = PushAccess::Whitelist;
135 desired.push_whitelist_usernames = vec!["scootermon".to_owned()];
136 let existing = BranchProtection {
137 enable_push: Some(true),
138 enable_push_whitelist: Some(false),
139 ..BranchProtection::default()
140 };
141 let (patch, _) = EditBranchProtectionOption::between(&desired, &existing);
142 assert_eq!(patch.enable_push, Some(true));
143 assert_eq!(patch.enable_push_whitelist, Some(true));
144 assert_eq!(
145 patch.push_whitelist_usernames,
146 Some(vec!["scootermon".to_owned()])
147 );
148 }
149
150 #[test]
151 fn branch_rule_names_that_do_not_fit_a_route_do_not_decode() {
152 for name in ["bad/name", "bad?name", "bad#name", ""] {
153 assert_eq!(
154 RuleName::try_from(name.to_owned()),
155 Err(InvalidRuleName {
156 name: name.to_owned()
157 })
158 );
159 }
160 }
161
162 #[tokio::test]
163 async fn a_create_race_recovers_by_patching_only_the_differences() {
164 let server = Server::start(vec![
165 Response::new(403, "already exists"),
166 Response::new(200, r#"{"rule_name":"main"}"#),
167 Response::new(
168 200,
169 r#"{"rule_name":"main","apply_to_admins":false,"enable_status_check":true,"required_approvals":2,"status_check_contexts":["a","b"]}"#,
170 ),
171 Response::new(200, "{}"),
172 ]);
173 let client = Client::new(
174 HttpApi::new(&server.url(), Token::new("secret")).expect("the client builds"),
175 );
176 let operation = Operation::BranchRules(Rule::Create {
177 name: "main".to_owned(),
178 rule: rule(),
179 });
180 client
181 .apply(&target(), &operation)
182 .await
183 .expect("the race is recovered by a patch");
184 let requests = server.requests();
185 let methods: Vec<_> = requests
186 .iter()
187 .map(|request| request.method.as_str())
188 .collect();
189 assert_eq!(methods, ["POST", "GET", "GET", "PATCH"]);
190 assert_eq!(
191 requests[3].path,
192 "/api/v1/repos/acme/demo/branch_protections/main"
193 );
194 assert!(requests[3].body.contains(r#""apply_to_admins":true"#));
195 assert!(!requests[3].body.contains("required_approvals"));
196 assert!(!requests[3].body.contains("status_check_contexts"));
197 }
198}