1use serde::{Deserialize, Serialize};
10
11use crate::bootstrap::MergeMethod;
12use crate::model::{
13 ForgeAccount, Projection, ProtectionState, RepoState, RoleAssignment, Visibility,
14};
15use crate::resource::Resource;
16use crate::rights::ForgeRole;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21#[non_exhaustive]
22pub struct ForgeEvent {
23 pub delivery_id: Option<String>,
27 pub kind: ForgeEventKind,
29}
30
31impl ForgeEvent {
32 pub fn new(delivery_id: Option<String>, kind: ForgeEventKind) -> Self {
34 ForgeEvent { delivery_id, kind }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41#[non_exhaustive]
42pub enum MemberChange {
43 Added,
45 Removed,
47 Edited,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub enum InstallationChange {
56 Created,
58 Deleted,
60 Suspended,
62 Unsuspended,
64 PermissionsAccepted,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", tag = "type")]
71#[non_exhaustive]
72pub enum ForgeEventKind {
73 RepoCreated {
75 repo: Resource,
77 forge_id: u64,
79 },
80 RepoDeleted {
82 repo: Resource,
84 forge_id: u64,
86 },
87 RepoRenamed {
89 forge_id: u64,
91 from: Resource,
93 to: Resource,
95 },
96 RepoTransferred {
98 forge_id: u64,
100 from_namespace: Option<Resource>,
102 to: Resource,
104 },
105 RepoArchived {
107 repo: Resource,
109 forge_id: u64,
111 archived: bool,
113 },
114 RepoVisibilityChanged {
116 repo: Resource,
118 forge_id: u64,
120 visibility: Visibility,
122 },
123 CollaboratorChanged {
125 repo: Resource,
127 forge_id: u64,
129 account: ForgeAccount,
131 change: MemberChange,
133 },
134 OrgMembershipChanged {
136 namespace: Resource,
138 account: ForgeAccount,
140 change: MemberChange,
142 },
143 TeamMembershipChanged {
147 namespace: Resource,
149 team: String,
151 account: ForgeAccount,
153 change: MemberChange,
155 },
156 ProtectionChanged {
159 repo: Option<Resource>,
161 namespace: Resource,
163 action: String,
166 },
167 InstallationChanged {
169 namespace: Resource,
171 installation_id: u64,
173 change: InstallationChange,
175 },
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase", tag = "type")]
181#[non_exhaustive]
182pub enum ProtectionGap {
183 Missing,
185 NotEnforced,
187 DefaultBranchNotCovered,
189 PullRequestNotRequired,
191 CheckNotRequired {
193 check: String,
195 },
196 ForcePushAllowed,
198 DeletionAllowed,
200 BypassActors {
202 actors: Vec<String>,
204 },
205 CheckSourceUnprotected {
209 detail: String,
211 },
212 UnprotectedPaths {
215 paths: Vec<String>,
217 },
218 MergeMethodAllowed {
221 method: MergeMethod,
223 },
224 CiDisabled,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase", tag = "type")]
231#[non_exhaustive]
232pub enum Drift {
233 UnexpectedRole {
236 account: ForgeAccount,
238 observed: ForgeRole,
240 },
241 MissingRole {
243 account: ForgeAccount,
245 expected: ForgeRole,
247 },
248 RoleMismatch {
250 account: ForgeAccount,
252 expected: ForgeRole,
254 observed: ForgeRole,
256 },
257 ProtectionWeakened {
260 gaps: Vec<ProtectionGap>,
262 },
263 Renamed {
266 expected: Resource,
268 observed: Resource,
270 },
271 Replaced {
274 expected: u64,
276 observed: u64,
278 },
279 ArchiveMismatch {
281 expected: bool,
283 observed: bool,
285 },
286 VisibilityMismatch {
288 expected: Visibility,
290 observed: Visibility,
292 },
293 ReplanNeeded {
297 reason: String,
299 },
300}
301
302impl Drift {
303 pub fn is_critical(&self) -> bool {
308 matches!(
309 self,
310 Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
311 )
312 }
313}
314
315pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
317 if !observed.present {
318 let mut gaps = vec![ProtectionGap::Missing];
319 gaps.extend(observed.other_gaps.iter().cloned());
320 return gaps;
321 }
322 let mut gaps = Vec::new();
323 if !observed.enforced {
324 gaps.push(ProtectionGap::NotEnforced);
325 }
326 if !observed.covers_default_branch {
327 gaps.push(ProtectionGap::DefaultBranchNotCovered);
328 }
329 if !observed.requires_pull_request {
330 gaps.push(ProtectionGap::PullRequestNotRequired);
331 }
332 if !observed.required_checks.iter().any(|c| c == check) {
333 gaps.push(ProtectionGap::CheckNotRequired {
334 check: check.to_string(),
335 });
336 }
337 if !observed.blocks_force_push {
338 gaps.push(ProtectionGap::ForcePushAllowed);
339 }
340 if !observed.blocks_deletion {
341 gaps.push(ProtectionGap::DeletionAllowed);
342 }
343 if !observed.bypass_actors.is_empty() {
344 gaps.push(ProtectionGap::BypassActors {
345 actors: observed.bypass_actors.clone(),
346 });
347 }
348 gaps.extend(observed.other_gaps.iter().cloned());
349 gaps
350}
351
352pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
356 let mut drift = Vec::new();
357
358 if let Some(expected) = desired.forge_id
359 && expected != observed.forge_id
360 {
361 return vec![Drift::Replaced {
364 expected,
365 observed: observed.forge_id,
366 }];
367 }
368 if observed.resource != desired.resource {
369 drift.push(Drift::Renamed {
370 expected: desired.resource.clone(),
371 observed: observed.resource.clone(),
372 });
373 }
374 if observed.archived != desired.archived {
375 drift.push(Drift::ArchiveMismatch {
376 expected: desired.archived,
377 observed: observed.archived,
378 });
379 }
380 if let Some(expected) = desired.visibility
381 && expected != observed.visibility
382 {
383 drift.push(Drift::VisibilityMismatch {
384 expected,
385 observed: observed.visibility,
386 });
387 }
388 if let Some(check) = &desired.required_check {
389 let gaps = protection_gaps(&observed.protection, check);
390 if !gaps.is_empty() {
391 drift.push(Drift::ProtectionWeakened { gaps });
392 }
393 }
394
395 drift.extend(role_drift(observed, &desired.roles));
396 drift
397}
398
399fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
400 let mut drift = Vec::new();
401 for want in desired {
402 let have = observed
403 .collaborators
404 .iter()
405 .find(|c| c.account.id == want.account.id);
406 match have {
407 None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
408 account: want.account.clone(),
409 expected: want.role,
410 }),
411 Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
412 account: c.account.clone(),
413 observed: c.role,
414 }),
415 Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
416 account: c.account.clone(),
417 expected: want.role,
418 observed: c.role,
419 }),
420 _ => {}
421 }
422 }
423 for c in &observed.collaborators {
424 if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
425 drift.push(Drift::UnexpectedRole {
426 account: c.account.clone(),
427 observed: c.role,
428 });
429 }
430 }
431 drift
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::model::Collaborator;
438
439 fn res(s: &str) -> Resource {
440 Resource::parse(s).unwrap()
441 }
442
443 fn protected(check: &str) -> ProtectionState {
444 ProtectionState {
445 present: true,
446 enforced: true,
447 covers_default_branch: true,
448 requires_pull_request: true,
449 required_checks: vec![check.into()],
450 blocks_force_push: true,
451 blocks_deletion: true,
452 bypass_actors: vec![],
453 ..ProtectionState::default()
454 }
455 }
456
457 fn alice() -> ForgeAccount {
458 ForgeAccount::new(1, "alice")
459 }
460 fn bob() -> ForgeAccount {
461 ForgeAccount::new(2, "bob")
462 }
463
464 #[test]
465 fn a_matching_repo_has_no_drift() {
466 let mut state = RepoState::new(res("github.com/acme/w"), 9);
467 state.protection = protected("Verify commit trust");
468 state.collaborators = vec![
469 Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
470 Collaborator::invited(bob(), ForgeRole::Maintain),
471 ];
472 let mut want = Projection::new(res("github.com/acme/w"));
473 want.forge_id = Some(9);
474 want.required_check = Some("Verify commit trust".into());
475 want.roles = vec![
476 RoleAssignment::new(alice(), ForgeRole::Admin),
477 RoleAssignment::new(bob(), ForgeRole::Maintain),
478 ];
479 assert_eq!(default_diff(&state, &want), vec![]);
480 }
481
482 #[test]
483 fn role_drift_is_matched_by_id() {
484 let mut state = RepoState::new(res("github.com/acme/w"), 9);
485 state.collaborators = vec![
486 Collaborator::new(alice(), ForgeRole::Write),
487 Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
488 ];
489 let mut want = Projection::new(res("github.com/acme/w"));
490 want.roles = vec![
491 RoleAssignment::new(alice(), ForgeRole::Admin),
492 RoleAssignment::new(bob(), ForgeRole::Maintain),
493 ];
494 let drift = default_diff(&state, &want);
495 assert_eq!(
496 drift,
497 vec![
498 Drift::RoleMismatch {
499 account: alice(),
500 expected: ForgeRole::Admin,
501 observed: ForgeRole::Write
502 },
503 Drift::MissingRole {
504 account: bob(),
505 expected: ForgeRole::Maintain
506 },
507 Drift::UnexpectedRole {
508 account: ForgeAccount::new(3, "mallory"),
509 observed: ForgeRole::Admin
510 },
511 ]
512 );
513 assert!(!drift.iter().any(Drift::is_critical));
514 }
515
516 #[test]
517 fn weakened_protection_lists_every_gap() {
518 let mut state = RepoState::new(res("github.com/acme/w"), 9);
519 let mut p = protected("something else");
520 p.enforced = false;
521 p.blocks_force_push = false;
522 p.bypass_actors = vec!["OrganizationAdmin".into()];
523 state.protection = p;
524 let mut want = Projection::new(res("github.com/acme/w"));
525 want.required_check = Some("Verify commit trust".into());
526 let drift = default_diff(&state, &want);
527 assert_eq!(
528 drift,
529 vec![Drift::ProtectionWeakened {
530 gaps: vec![
531 ProtectionGap::NotEnforced,
532 ProtectionGap::CheckNotRequired {
533 check: "Verify commit trust".into()
534 },
535 ProtectionGap::ForcePushAllowed,
536 ProtectionGap::BypassActors {
537 actors: vec!["OrganizationAdmin".into()]
538 },
539 ]
540 }]
541 );
542 assert!(drift[0].is_critical());
543
544 state.protection = ProtectionState::default();
545 assert_eq!(
546 default_diff(&state, &want),
547 vec![Drift::ProtectionWeakened {
548 gaps: vec![ProtectionGap::Missing]
549 }]
550 );
551 }
552
553 #[test]
554 fn gaps_in_what_guards_the_workflow_are_reported_too() {
555 let mut state = RepoState::new(res("github.com/acme/w"), 9);
556 let unprotected = ProtectionGap::CheckSourceUnprotected {
557 detail: "the org ruleset is missing".into(),
558 };
559 state.protection = protected("Verify commit trust");
560 state.protection.other_gaps = vec![unprotected.clone()];
561 let mut want = Projection::new(res("github.com/acme/w"));
562 want.required_check = Some("Verify commit trust".into());
563 let drift = default_diff(&state, &want);
564 assert_eq!(
565 drift,
566 vec![Drift::ProtectionWeakened {
567 gaps: vec![unprotected.clone()]
568 }]
569 );
570 assert!(drift[0].is_critical());
571
572 state.protection = ProtectionState {
574 other_gaps: vec![unprotected.clone()],
575 ..ProtectionState::default()
576 };
577 assert_eq!(
578 protection_gaps(&state.protection, "Verify commit trust"),
579 vec![ProtectionGap::Missing, unprotected]
580 );
581 }
582
583 #[test]
584 fn rename_and_replacement_are_told_apart_by_forge_id() {
585 let state = RepoState::new(res("github.com/acme/new-name"), 9);
586 let mut want = Projection::new(res("github.com/acme/w"));
587 want.forge_id = Some(9);
588 assert_eq!(
589 default_diff(&state, &want),
590 vec![Drift::Renamed {
591 expected: res("github.com/acme/w"),
592 observed: res("github.com/acme/new-name")
593 }]
594 );
595
596 let imposter = RepoState::new(res("github.com/acme/w"), 10);
597 assert_eq!(
598 default_diff(&imposter, &want),
599 vec![Drift::Replaced {
600 expected: 9,
601 observed: 10
602 }]
603 );
604 }
605}