Skip to main content

ephemeris_forge_forgejo/api/request/
branch_rules.rs

1use ephemeris_forge::{FieldDiff, NameSet};
2use facet::Facet;
3
4use crate::api::model::BranchProtection;
5use crate::{BranchRule, PushAccess};
6
7macro_rules! compare {
8    ($diff:ident, $edit:ident, $desired:ident, $live:ident, $($field:ident),+ $(,)?) => {$(
9        if let Some(wanted) =
10            $diff.want(stringify!($field), &$desired.$field, $live.$field.as_ref())
11        {
12            $edit.$field = Some(wanted);
13        }
14    )+};
15}
16
17macro_rules! compare_sets {
18    ($diff:ident, $edit:ident, $desired:ident, $live:ident, $($field:ident),+ $(,)?) => {$(
19        let wanted = NameSet::new($desired.$field.iter().cloned());
20        let current = $live.$field.clone().map(NameSet::new);
21        if let Some(set) = $diff.want_set(stringify!($field), &wanted, current.as_ref()) {
22            $edit.$field = Some(set.into_vec());
23        }
24    )+};
25}
26
27macro_rules! compare_patterns {
28    ($diff:ident, $edit:ident, $desired:ident, $live:ident, $($field:ident),+ $(,)?) => {$(
29        let wanted = NameSet::new($desired.$field.iter().cloned());
30        let current = $live.$field.as_deref().map(NameSet::from_patterns);
31        if let Some(set) = $diff.want_set(stringify!($field), &wanted, current.as_ref()) {
32            $edit.$field = Some(set.to_patterns());
33        }
34    )+};
35}
36
37/// The managed subset of `EditBranchProtectionOption`.
38///
39/// Pointers with `omitempty`, so only the differing properties are sent.
40#[derive(Clone, Debug, Default, Eq, Facet, PartialEq)]
41#[facet(skip_all_unless_truthy)]
42pub struct EditBranchProtectionOption {
43    /// Whether the protection also applies to repository administrators.
44    pub apply_to_admins: Option<bool>,
45    /// The teams whose approvals count, when the approvals whitelist is on.
46    pub approvals_whitelist_teams: Option<Vec<String>>,
47    /// The users whose approvals count, when the approvals whitelist is on.
48    pub approvals_whitelist_username: Option<Vec<String>>,
49    /// Whether an official code owner review request blocks merging until
50    /// resolved.
51    pub block_on_official_review_requests: Option<bool>,
52    /// Whether merging is blocked while the branch is behind its base.
53    pub block_on_outdated_branch: Option<bool>,
54    /// Whether a requested change blocks merging until it is resolved.
55    pub block_on_rejected_reviews: Option<bool>,
56    /// Whether a new commit dismisses existing approvals.
57    pub dismiss_stale_approvals: Option<bool>,
58    /// Whether only the approvals whitelist counts toward the required
59    /// approvals.
60    pub enable_approvals_whitelist: Option<bool>,
61    /// Whether only the merge whitelist may merge.
62    pub enable_merge_whitelist: Option<bool>,
63    /// Whether direct pushes to the branch are allowed at all.
64    pub enable_push: Option<bool>,
65    /// Whether only the push whitelist may push.
66    pub enable_push_whitelist: Option<bool>,
67    /// Whether status checks must pass before merging.
68    pub enable_status_check: Option<bool>,
69    /// Whether approvals on an outdated commit stop counting.
70    pub ignore_stale_approvals: Option<bool>,
71    /// The teams that may merge, when the merge whitelist is on.
72    pub merge_whitelist_teams: Option<Vec<String>>,
73    /// The users that may merge, when the merge whitelist is on.
74    pub merge_whitelist_usernames: Option<Vec<String>>,
75    /// Semicolon separated glob patterns for files that only whitelisted
76    /// pushers may change.
77    pub protected_file_patterns: Option<String>,
78    /// Whether deploy keys may push, when the push whitelist is on.
79    pub push_whitelist_deploy_keys: Option<bool>,
80    /// The teams that may push, when the push whitelist is on.
81    pub push_whitelist_teams: Option<Vec<String>>,
82    /// The users that may push, when the push whitelist is on.
83    pub push_whitelist_usernames: Option<Vec<String>>,
84    /// Whether every commit on the branch must be signed.
85    pub require_signed_commits: Option<bool>,
86    /// The number of approving reviews required before merging.
87    pub required_approvals: Option<i64>,
88    /// The status check contexts that must report success.
89    pub status_check_contexts: Option<Vec<String>>,
90    /// Semicolon separated glob patterns for files that any pusher may
91    /// change despite the protected patterns.
92    pub unprotected_file_patterns: Option<String>,
93}
94
95/// The managed subset of `CreateBranchProtectionOption`.
96///
97/// Every property is plain `bool`, `int64` or `[]string` with no `omitempty`,
98/// so Forgejo stores an omitted property as `false`, `0` or empty and still
99/// reports success. A differential `POST` would therefore create a rule with
100/// protection switched off. Every managed property is sent on every create.
101///
102/// The deprecated `branch_name` is never sent: `rule_name` supersedes it, and
103/// sending both lets the forge pick.
104#[derive(Clone, Debug, Eq, Facet, PartialEq)]
105#[expect(
106    clippy::struct_excessive_bools,
107    reason = "this mirrors CreateBranchProtectionOption, whose plain bools are the point"
108)]
109pub struct CreateBranchProtectionOption {
110    /// The rule's name.
111    pub rule_name: String,
112    /// Whether the protection also applies to repository administrators.
113    pub apply_to_admins: bool,
114    /// The teams whose approvals count, when the approvals whitelist is on.
115    pub approvals_whitelist_teams: Vec<String>,
116    /// The users whose approvals count, when the approvals whitelist is on.
117    pub approvals_whitelist_username: Vec<String>,
118    /// Whether an official code owner review request blocks merging until
119    /// resolved.
120    pub block_on_official_review_requests: bool,
121    /// Whether merging is blocked while the branch is behind its base.
122    pub block_on_outdated_branch: bool,
123    /// Whether a requested change blocks merging until it is resolved.
124    pub block_on_rejected_reviews: bool,
125    /// Whether a new commit dismisses existing approvals.
126    pub dismiss_stale_approvals: bool,
127    /// Whether only the approvals whitelist counts toward the required
128    /// approvals.
129    pub enable_approvals_whitelist: bool,
130    /// Whether only the merge whitelist may merge.
131    pub enable_merge_whitelist: bool,
132    /// Whether direct pushes to the branch are allowed at all.
133    pub enable_push: bool,
134    /// Whether only the push whitelist may push.
135    pub enable_push_whitelist: bool,
136    /// Whether status checks must pass before merging.
137    pub enable_status_check: bool,
138    /// Whether approvals on an outdated commit stop counting.
139    pub ignore_stale_approvals: bool,
140    /// The teams that may merge, when the merge whitelist is on.
141    pub merge_whitelist_teams: Vec<String>,
142    /// The users that may merge, when the merge whitelist is on.
143    pub merge_whitelist_usernames: Vec<String>,
144    /// Semicolon separated glob patterns for files that only whitelisted
145    /// pushers may change.
146    pub protected_file_patterns: String,
147    /// Whether deploy keys may push, when the push whitelist is on.
148    pub push_whitelist_deploy_keys: bool,
149    /// The teams that may push, when the push whitelist is on.
150    pub push_whitelist_teams: Vec<String>,
151    /// The users that may push, when the push whitelist is on.
152    pub push_whitelist_usernames: Vec<String>,
153    /// Whether every commit on the branch must be signed.
154    pub require_signed_commits: bool,
155    /// The number of approving reviews required before merging.
156    pub required_approvals: i64,
157    /// The status check contexts that must report success.
158    pub status_check_contexts: Vec<String>,
159    /// Semicolon separated glob patterns for files that any pusher may
160    /// change despite the protected patterns.
161    pub unprotected_file_patterns: String,
162}
163
164impl EditBranchProtectionOption {
165    /// The body that writes the differences of `desired` from `actual`, with
166    /// the comparison of every field.
167    #[must_use]
168    pub fn between(desired: &BranchRule, actual: &BranchProtection) -> (Self, FieldDiff) {
169        let mut diff = FieldDiff::default();
170        let mut edit = Self::default();
171        compare!(
172            diff,
173            edit,
174            desired,
175            actual,
176            apply_to_admins,
177            block_on_official_review_requests,
178            block_on_outdated_branch,
179            block_on_rejected_reviews,
180            dismiss_stale_approvals,
181            enable_approvals_whitelist,
182            enable_merge_whitelist,
183            enable_status_check,
184            ignore_stale_approvals,
185            push_whitelist_deploy_keys,
186            require_signed_commits,
187            required_approvals,
188        );
189        compare_sets!(
190            diff,
191            edit,
192            desired,
193            actual,
194            approvals_whitelist_teams,
195            approvals_whitelist_username,
196            merge_whitelist_teams,
197            merge_whitelist_usernames,
198            push_whitelist_teams,
199            push_whitelist_usernames,
200            status_check_contexts,
201        );
202        compare_patterns!(
203            diff,
204            edit,
205            desired,
206            actual,
207            protected_file_patterns,
208            unprotected_file_patterns,
209        );
210        let push_differs = diff.check(
211            "push",
212            actual.push_access() == Some(desired.push),
213            &actual.push_access(),
214            &desired.push,
215        );
216        if push_differs || edit.push_whitelist_deploy_keys.is_some() {
217            edit.carry_push(desired.push);
218        }
219        (edit, diff)
220    }
221
222    /// Whether the body would carry no property at all.
223    #[must_use]
224    pub fn is_empty(&self) -> bool {
225        *self == Self::default()
226    }
227
228    /// Carry both push flags. Forgejo only applies the whitelist and deploy
229    /// key flags when `enable_push` is in the same payload.
230    pub const fn carry_push(&mut self, push: PushAccess) {
231        self.enable_push = Some(push.enables_push());
232        self.enable_push_whitelist = Some(push.restricts_to_whitelist());
233    }
234}
235
236impl CreateBranchProtectionOption {
237    /// Build a complete create body from the desired state.
238    #[must_use]
239    pub fn new(rule_name: &str, desired: &BranchRule) -> Self {
240        Self {
241            rule_name: rule_name.to_owned(),
242            apply_to_admins: desired.apply_to_admins,
243            approvals_whitelist_teams: NameSet::new(
244                desired.approvals_whitelist_teams.iter().cloned(),
245            )
246            .into_vec(),
247            approvals_whitelist_username: NameSet::new(
248                desired.approvals_whitelist_username.iter().cloned(),
249            )
250            .into_vec(),
251            block_on_official_review_requests: desired.block_on_official_review_requests,
252            block_on_outdated_branch: desired.block_on_outdated_branch,
253            block_on_rejected_reviews: desired.block_on_rejected_reviews,
254            dismiss_stale_approvals: desired.dismiss_stale_approvals,
255            enable_approvals_whitelist: desired.enable_approvals_whitelist,
256            enable_merge_whitelist: desired.enable_merge_whitelist,
257            enable_push: desired.push.enables_push(),
258            enable_push_whitelist: desired.push.restricts_to_whitelist(),
259            enable_status_check: desired.enable_status_check,
260            ignore_stale_approvals: desired.ignore_stale_approvals,
261            merge_whitelist_teams: NameSet::new(desired.merge_whitelist_teams.iter().cloned())
262                .into_vec(),
263            merge_whitelist_usernames: NameSet::new(
264                desired.merge_whitelist_usernames.iter().cloned(),
265            )
266            .into_vec(),
267            protected_file_patterns: NameSet::new(desired.protected_file_patterns.iter().cloned())
268                .to_patterns(),
269            push_whitelist_deploy_keys: desired.push_whitelist_deploy_keys,
270            push_whitelist_teams: NameSet::new(desired.push_whitelist_teams.iter().cloned())
271                .into_vec(),
272            push_whitelist_usernames: NameSet::new(
273                desired.push_whitelist_usernames.iter().cloned(),
274            )
275            .into_vec(),
276            require_signed_commits: desired.require_signed_commits,
277            required_approvals: desired.required_approvals,
278            status_check_contexts: desired.contexts().into_vec(),
279            unprotected_file_patterns: NameSet::new(
280                desired.unprotected_file_patterns.iter().cloned(),
281            )
282            .to_patterns(),
283        }
284    }
285}