Skip to main content

ephemeris_forge_forgejo/reconcile/
branch_rules.rs

1use 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}