Skip to main content

ephemeris_forge_github/config/
rulesets.rs

1mod rules;
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use ephemeris_forge::{Mode, non_empty_name};
7use facet::Facet;
8
9pub use self::rules::{
10    AlertsThreshold, CodeScanningRule, CodeScanningTool, DeploymentsRule, DismissalActor,
11    DismissalActorType, DismissalRestriction, GroupingStrategy, MergeMethod, MergeQueueRule,
12    PatternOperator, PatternRule, PullRequestRule, QueueMergeMethod, RequiredCheck, Rules,
13    SecurityAlertsThreshold, StatusChecksRule, UpdateRule, WorkflowRef, WorkflowsRule,
14};
15pub(crate) use self::rules::{RuleDecoder, RuleRef};
16use super::ConfigError;
17
18non_empty_name! {
19    /// The name of a ruleset, which is never empty.
20    pub struct RulesetName;
21    /// A ruleset name was the empty string.
22    pub error EmptyRulesetName("ruleset name cannot be empty");
23}
24
25/// The GitHub id of the organization admin role in a bypass list.
26const ORGANIZATION_ADMIN_ID: i64 = 1;
27
28/// The kind of ref a ruleset covers.
29///
30/// A configuration can only declare `branch` and `tag`. A live ruleset can
31/// also have a target this tool does not know.
32#[derive(Clone, Debug, Eq, Facet, PartialEq)]
33#[facet(proxy = String)]
34#[repr(u8)]
35pub enum Target {
36    /// Branches.
37    Branch,
38    /// Tags.
39    Tag,
40    /// Any other target, kept as GitHub spelled it.
41    Unknown(String),
42}
43
44/// A configuration declared a ruleset target other than `branch` or `tag`.
45#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
46#[error("unknown ruleset target {name:?}")]
47pub struct UnknownTarget {
48    /// The target as written.
49    pub name: String,
50}
51
52impl Target {
53    /// Read the target of a live ruleset, which may be one the configuration
54    /// cannot declare.
55    #[must_use]
56    pub fn observed(raw: &str) -> Self {
57        Self::try_from(raw.to_owned()).unwrap_or_else(|UnknownTarget { name }| Self::Unknown(name))
58    }
59}
60
61impl TryFrom<String> for Target {
62    type Error = UnknownTarget;
63
64    fn try_from(name: String) -> Result<Self, Self::Error> {
65        match name.as_str() {
66            "branch" => Ok(Self::Branch),
67            "tag" => Ok(Self::Tag),
68            _ => Err(UnknownTarget { name }),
69        }
70    }
71}
72
73impl From<&Target> for String {
74    fn from(value: &Target) -> Self {
75        match value {
76            Target::Branch => "branch".to_owned(),
77            Target::Tag => "tag".to_owned(),
78            Target::Unknown(name) => name.clone(),
79        }
80    }
81}
82
83/// Whether a ruleset blocks, only reports, or does nothing.
84#[derive(Clone, Copy, Debug, Eq, Facet, Ord, PartialEq, PartialOrd)]
85#[repr(u8)]
86pub enum Enforcement {
87    /// Violations are blocked.
88    #[facet(rename = "active")]
89    Active,
90    /// Violations are only reported.
91    #[facet(rename = "evaluate")]
92    Evaluate,
93    /// The ruleset is off.
94    #[facet(rename = "disabled")]
95    Disabled,
96}
97
98/// The kind of actor that may bypass a ruleset.
99#[derive(Clone, Copy, Debug, Eq, Facet, Ord, PartialEq, PartialOrd)]
100#[repr(u8)]
101pub enum ActorType {
102    /// A GitHub App.
103    #[facet(rename = "Integration")]
104    Integration,
105    /// Organization owners.
106    #[facet(rename = "OrganizationAdmin")]
107    OrganizationAdmin,
108    /// A repository role.
109    #[facet(rename = "RepositoryRole")]
110    RepositoryRole,
111    /// An organization team.
112    #[facet(rename = "Team")]
113    Team,
114    /// A user.
115    #[facet(rename = "User")]
116    User,
117    /// A deploy key.
118    #[facet(rename = "DeployKey")]
119    DeployKey,
120}
121
122impl ActorType {
123    /// The name GitHub uses for the actor type.
124    #[must_use]
125    pub const fn as_str(self) -> &'static str {
126        match self {
127            Self::Integration => "Integration",
128            Self::OrganizationAdmin => "OrganizationAdmin",
129            Self::RepositoryRole => "RepositoryRole",
130            Self::Team => "Team",
131            Self::User => "User",
132            Self::DeployKey => "DeployKey",
133        }
134    }
135
136    /// Whether a bypass actor of this type needs an id.
137    #[must_use]
138    pub const fn needs_id(self) -> bool {
139        matches!(self, Self::RepositoryRole | Self::Team | Self::User)
140    }
141
142    /// Whether a bypass actor of this type must not have an id.
143    #[must_use]
144    pub const fn forbids_id(self) -> bool {
145        matches!(self, Self::DeployKey)
146    }
147}
148
149impl fmt::Display for ActorType {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str(self.as_str())
152    }
153}
154
155/// When a bypass actor may skip the ruleset.
156#[derive(Clone, Copy, Debug, Eq, Facet, Ord, PartialEq, PartialOrd)]
157#[repr(u8)]
158pub enum BypassMode {
159    /// Always.
160    #[facet(rename = "always")]
161    Always,
162    /// Only through a pull request.
163    #[facet(rename = "pull_request")]
164    PullRequest,
165    /// The actor is exempt from the ruleset.
166    #[facet(rename = "exempt")]
167    Exempt,
168}
169
170/// An actor that may bypass a ruleset.
171#[derive(Clone, Copy, Debug, Eq, Facet, Ord, PartialEq, PartialOrd)]
172#[facet(deny_unknown_fields)]
173pub struct BypassActor {
174    /// The kind of actor.
175    pub actor_type: ActorType,
176    /// The actor's id, which organization admins and deploy keys do not need.
177    /// An integration without one is the App the client acts as.
178    pub actor_id: Option<i64>,
179    /// When the actor may bypass.
180    pub bypass_mode: BypassMode,
181}
182
183impl BypassActor {
184    /// The id GitHub expects in a write.
185    ///
186    /// An organization admin without an id gets the admin role id. Other
187    /// types send their own id, so an integration must have its App id filled
188    /// in before it is written.
189    #[must_use]
190    pub const fn wire_id(&self) -> Option<i64> {
191        match (self.actor_type, self.actor_id) {
192            (ActorType::OrganizationAdmin, None) => Some(ORGANIZATION_ADMIN_ID),
193            (_, id) => id,
194        }
195    }
196
197    const fn is_own_app(&self) -> bool {
198        matches!(self.actor_type, ActorType::Integration) && self.actor_id.is_none()
199    }
200
201    const fn normalized(mut self) -> Self {
202        if matches!(self.actor_type, ActorType::OrganizationAdmin) {
203            self.actor_id = None;
204        }
205        self
206    }
207}
208
209/// A GitHub branch or tag ruleset, without its name.
210#[derive(Clone, Debug, Eq, Facet, PartialEq)]
211#[facet(deny_unknown_fields)]
212pub struct Ruleset {
213    /// The kind of ref the ruleset covers.
214    pub target: Target,
215    /// Whether the ruleset blocks, only reports, or does nothing.
216    pub enforcement: Enforcement,
217    /// Ref patterns the ruleset covers, such as `~DEFAULT_BRANCH`.
218    pub include: Vec<String>,
219    /// Ref patterns the ruleset skips.
220    pub exclude: Vec<String>,
221    /// Actors that may bypass the ruleset.
222    pub bypass_actors: Vec<BypassActor>,
223    /// The rules the ruleset applies.
224    pub rules: Rules,
225}
226
227impl Ruleset {
228    /// Whether a bypass actor stands for the App the client acts as.
229    pub(crate) fn bypassed_by_own_app(&self) -> bool {
230        self.bypass_actors.iter().any(BypassActor::is_own_app)
231    }
232
233    /// Give the bypass actors that stand for the acting App its id.
234    #[must_use]
235    pub(crate) fn with_own_app(mut self, app_id: i64) -> Self {
236        for actor in self
237            .bypass_actors
238            .iter_mut()
239            .filter(|actor| actor.is_own_app())
240        {
241            actor.actor_id = Some(app_id);
242        }
243        self
244    }
245
246    /// Put every list in its comparison order and drop what GitHub fills in.
247    #[must_use]
248    pub fn normalized(mut self) -> Self {
249        self.include.sort_unstable();
250        self.include.dedup();
251        self.exclude.sort_unstable();
252        self.exclude.dedup();
253        self.bypass_actors = self
254            .bypass_actors
255            .into_iter()
256            .map(BypassActor::normalized)
257            .collect();
258        self.bypass_actors.sort_unstable();
259        self.bypass_actors.dedup();
260        self.rules.normalize();
261        self
262    }
263}
264
265/// Desired rulesets of a repository.
266#[derive(Clone, Debug, Eq, Facet, PartialEq)]
267#[facet(deny_unknown_fields)]
268pub struct Rulesets {
269    /// Whether rulesets that are not declared are kept or deleted.
270    pub mode: Mode,
271    /// The rulesets keyed by name.
272    pub rulesets: BTreeMap<RulesetName, Ruleset>,
273}
274
275impl Ruleset {
276    fn validate(&self, name: &RulesetName) -> Result<(), ConfigError> {
277        if let Some(actor) = self
278            .bypass_actors
279            .iter()
280            .find(|actor| actor.actor_type.needs_id() && actor.actor_id.is_none())
281        {
282            return Err(ConfigError::MissingActorId {
283                ruleset: name.clone(),
284                actor_type: actor.actor_type,
285            });
286        }
287        if let Some(actor) = self
288            .bypass_actors
289            .iter()
290            .find(|actor| actor.actor_type.forbids_id() && actor.actor_id.is_some())
291        {
292            return Err(ConfigError::UnexpectedActorId {
293                ruleset: name.clone(),
294                actor_type: actor.actor_type,
295            });
296        }
297        if self
298            .rules
299            .pull_request
300            .as_ref()
301            .is_some_and(|rule| rule.allowed_merge_methods.is_empty())
302        {
303            return Err(ConfigError::NoMergeMethods {
304                ruleset: name.clone(),
305            });
306        }
307        Ok(())
308    }
309}
310
311impl Rulesets {
312    /// Check what decoding cannot.
313    ///
314    /// # Errors
315    /// Returns an error when a ruleset has a value GitHub refuses.
316    pub fn validate(&self) -> Result<(), ConfigError> {
317        self.rulesets
318            .iter()
319            .try_for_each(|(name, ruleset)| ruleset.validate(name))
320    }
321}