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 PullRequestOpened {
171 repo: Resource,
173 forge_id: u64,
175 number: u64,
177 reopened: bool,
179 author: ForgeAccount,
181 actor: ForgeAccount,
184 draft: Option<bool>,
186 from_fork: Option<bool>,
189 },
190 InstallationChanged {
192 namespace: Resource,
194 installation_id: u64,
196 change: InstallationChange,
198 },
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", tag = "type")]
204#[non_exhaustive]
205pub enum ProtectionGap {
206 Missing,
208 NotEnforced,
210 DefaultBranchNotCovered,
212 PullRequestNotRequired,
214 CheckNotRequired {
216 check: String,
218 },
219 ForcePushAllowed,
221 DeletionAllowed,
223 BypassActors {
225 actors: Vec<String>,
227 },
228 CheckSourceUnprotected {
232 detail: String,
234 },
235 UnprotectedPaths {
238 paths: Vec<String>,
240 },
241 MergeMethodAllowed {
244 method: MergeMethod,
246 },
247 CiDisabled,
249 ApprovalsBelow {
252 required: u8,
254 observed: u8,
256 },
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase", tag = "type")]
262#[non_exhaustive]
263pub enum Drift {
264 UnexpectedRole {
267 account: ForgeAccount,
269 observed: ForgeRole,
271 },
272 MissingRole {
274 account: ForgeAccount,
276 expected: ForgeRole,
278 },
279 RoleMismatch {
281 account: ForgeAccount,
283 expected: ForgeRole,
285 observed: ForgeRole,
287 },
288 ProtectionWeakened {
291 gaps: Vec<ProtectionGap>,
293 },
294 Renamed {
297 expected: Resource,
299 observed: Resource,
301 },
302 Replaced {
305 expected: u64,
307 observed: u64,
309 },
310 ArchiveMismatch {
312 expected: bool,
314 observed: bool,
316 },
317 VisibilityMismatch {
319 expected: Visibility,
321 observed: Visibility,
323 },
324 ReplanNeeded {
328 reason: String,
330 },
331}
332
333impl Drift {
334 pub fn is_critical(&self) -> bool {
339 matches!(
340 self,
341 Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
342 )
343 }
344}
345
346pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
348 if !observed.present {
349 let mut gaps = vec![ProtectionGap::Missing];
350 gaps.extend(observed.other_gaps.iter().cloned());
351 return gaps;
352 }
353 let mut gaps = Vec::new();
354 if !observed.enforced {
355 gaps.push(ProtectionGap::NotEnforced);
356 }
357 if !observed.covers_default_branch {
358 gaps.push(ProtectionGap::DefaultBranchNotCovered);
359 }
360 if !observed.requires_pull_request {
361 gaps.push(ProtectionGap::PullRequestNotRequired);
362 }
363 if !observed.required_checks.iter().any(|c| c == check) {
364 gaps.push(ProtectionGap::CheckNotRequired {
365 check: check.to_string(),
366 });
367 }
368 if !observed.blocks_force_push {
369 gaps.push(ProtectionGap::ForcePushAllowed);
370 }
371 if !observed.blocks_deletion {
372 gaps.push(ProtectionGap::DeletionAllowed);
373 }
374 if !observed.bypass_actors.is_empty() {
375 gaps.push(ProtectionGap::BypassActors {
376 actors: observed.bypass_actors.clone(),
377 });
378 }
379 gaps.extend(observed.other_gaps.iter().cloned());
380 gaps
381}
382
383pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
387 let mut drift = Vec::new();
388
389 if let Some(expected) = desired.forge_id
390 && expected != observed.forge_id
391 {
392 return vec![Drift::Replaced {
395 expected,
396 observed: observed.forge_id,
397 }];
398 }
399 if observed.resource != desired.resource {
400 drift.push(Drift::Renamed {
401 expected: desired.resource.clone(),
402 observed: observed.resource.clone(),
403 });
404 }
405 if observed.archived != desired.archived {
406 drift.push(Drift::ArchiveMismatch {
407 expected: desired.archived,
408 observed: observed.archived,
409 });
410 }
411 if let Some(expected) = desired.visibility
412 && expected != observed.visibility
413 {
414 drift.push(Drift::VisibilityMismatch {
415 expected,
416 observed: observed.visibility,
417 });
418 }
419 if let Some(check) = &desired.required_check {
420 let mut gaps = protection_gaps(&observed.protection, check);
421 if observed.protection.present
424 && observed.protection.required_approvals < desired.required_approvals
425 {
426 gaps.push(ProtectionGap::ApprovalsBelow {
427 required: desired.required_approvals,
428 observed: observed.protection.required_approvals,
429 });
430 }
431 if !gaps.is_empty() {
432 drift.push(Drift::ProtectionWeakened { gaps });
433 }
434 }
435
436 drift.extend(role_drift(observed, &desired.roles));
437 drift
438}
439
440fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
441 let mut drift = Vec::new();
442 for want in desired {
443 let have = observed
444 .collaborators
445 .iter()
446 .find(|c| c.account.id == want.account.id);
447 match have {
448 None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
449 account: want.account.clone(),
450 expected: want.role,
451 }),
452 Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
453 account: c.account.clone(),
454 observed: c.role,
455 }),
456 Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
457 account: c.account.clone(),
458 expected: want.role,
459 observed: c.role,
460 }),
461 _ => {}
462 }
463 }
464 for c in &observed.collaborators {
465 if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
466 drift.push(Drift::UnexpectedRole {
467 account: c.account.clone(),
468 observed: c.role,
469 });
470 }
471 }
472 drift
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::model::Collaborator;
479
480 fn res(s: &str) -> Resource {
481 Resource::parse(s).unwrap()
482 }
483
484 fn protected(check: &str) -> ProtectionState {
485 ProtectionState {
486 present: true,
487 enforced: true,
488 covers_default_branch: true,
489 requires_pull_request: true,
490 required_checks: vec![check.into()],
491 blocks_force_push: true,
492 blocks_deletion: true,
493 bypass_actors: vec![],
494 ..ProtectionState::default()
495 }
496 }
497
498 fn alice() -> ForgeAccount {
499 ForgeAccount::new(1, "alice")
500 }
501 fn bob() -> ForgeAccount {
502 ForgeAccount::new(2, "bob")
503 }
504
505 #[test]
509 fn fewer_approvals_than_required_is_drift_and_more_is_not() {
510 let mut state = RepoState::new(res("github.com/acme/w"), 9);
511 state.protection = protected("Verify commit trust");
512 let mut want = Projection::new(res("github.com/acme/w"));
513 want.forge_id = Some(9);
514 want.required_check = Some("Verify commit trust".into());
515 assert_eq!(default_diff(&state, &want), vec![]);
516
517 want.required_approvals = 2;
518 state.protection.required_approvals = 1;
519 assert_eq!(
520 default_diff(&state, &want),
521 vec![Drift::ProtectionWeakened {
522 gaps: vec![ProtectionGap::ApprovalsBelow {
523 required: 2,
524 observed: 1
525 }]
526 }]
527 );
528
529 state.protection.required_approvals = 3;
530 assert_eq!(default_diff(&state, &want), vec![]);
531 }
532
533 #[test]
534 fn a_matching_repo_has_no_drift() {
535 let mut state = RepoState::new(res("github.com/acme/w"), 9);
536 state.protection = protected("Verify commit trust");
537 state.collaborators = vec![
538 Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
539 Collaborator::invited(bob(), ForgeRole::Maintain),
540 ];
541 let mut want = Projection::new(res("github.com/acme/w"));
542 want.forge_id = Some(9);
543 want.required_check = Some("Verify commit trust".into());
544 want.roles = vec![
545 RoleAssignment::new(alice(), ForgeRole::Admin),
546 RoleAssignment::new(bob(), ForgeRole::Maintain),
547 ];
548 assert_eq!(default_diff(&state, &want), vec![]);
549 }
550
551 #[test]
552 fn role_drift_is_matched_by_id() {
553 let mut state = RepoState::new(res("github.com/acme/w"), 9);
554 state.collaborators = vec![
555 Collaborator::new(alice(), ForgeRole::Write),
556 Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
557 ];
558 let mut want = Projection::new(res("github.com/acme/w"));
559 want.roles = vec![
560 RoleAssignment::new(alice(), ForgeRole::Admin),
561 RoleAssignment::new(bob(), ForgeRole::Maintain),
562 ];
563 let drift = default_diff(&state, &want);
564 assert_eq!(
565 drift,
566 vec![
567 Drift::RoleMismatch {
568 account: alice(),
569 expected: ForgeRole::Admin,
570 observed: ForgeRole::Write
571 },
572 Drift::MissingRole {
573 account: bob(),
574 expected: ForgeRole::Maintain
575 },
576 Drift::UnexpectedRole {
577 account: ForgeAccount::new(3, "mallory"),
578 observed: ForgeRole::Admin
579 },
580 ]
581 );
582 assert!(!drift.iter().any(Drift::is_critical));
583 }
584
585 #[test]
586 fn weakened_protection_lists_every_gap() {
587 let mut state = RepoState::new(res("github.com/acme/w"), 9);
588 let mut p = protected("something else");
589 p.enforced = false;
590 p.blocks_force_push = false;
591 p.bypass_actors = vec!["OrganizationAdmin".into()];
592 state.protection = p;
593 let mut want = Projection::new(res("github.com/acme/w"));
594 want.required_check = Some("Verify commit trust".into());
595 let drift = default_diff(&state, &want);
596 assert_eq!(
597 drift,
598 vec![Drift::ProtectionWeakened {
599 gaps: vec![
600 ProtectionGap::NotEnforced,
601 ProtectionGap::CheckNotRequired {
602 check: "Verify commit trust".into()
603 },
604 ProtectionGap::ForcePushAllowed,
605 ProtectionGap::BypassActors {
606 actors: vec!["OrganizationAdmin".into()]
607 },
608 ]
609 }]
610 );
611 assert!(drift[0].is_critical());
612
613 state.protection = ProtectionState::default();
614 assert_eq!(
615 default_diff(&state, &want),
616 vec![Drift::ProtectionWeakened {
617 gaps: vec![ProtectionGap::Missing]
618 }]
619 );
620 }
621
622 #[test]
623 fn gaps_in_what_guards_the_workflow_are_reported_too() {
624 let mut state = RepoState::new(res("github.com/acme/w"), 9);
625 let unprotected = ProtectionGap::CheckSourceUnprotected {
626 detail: "the org ruleset is missing".into(),
627 };
628 state.protection = protected("Verify commit trust");
629 state.protection.other_gaps = vec![unprotected.clone()];
630 let mut want = Projection::new(res("github.com/acme/w"));
631 want.required_check = Some("Verify commit trust".into());
632 let drift = default_diff(&state, &want);
633 assert_eq!(
634 drift,
635 vec![Drift::ProtectionWeakened {
636 gaps: vec![unprotected.clone()]
637 }]
638 );
639 assert!(drift[0].is_critical());
640
641 state.protection = ProtectionState {
643 other_gaps: vec![unprotected.clone()],
644 ..ProtectionState::default()
645 };
646 assert_eq!(
647 protection_gaps(&state.protection, "Verify commit trust"),
648 vec![ProtectionGap::Missing, unprotected]
649 );
650 }
651
652 #[test]
653 fn rename_and_replacement_are_told_apart_by_forge_id() {
654 let state = RepoState::new(res("github.com/acme/new-name"), 9);
655 let mut want = Projection::new(res("github.com/acme/w"));
656 want.forge_id = Some(9);
657 assert_eq!(
658 default_diff(&state, &want),
659 vec![Drift::Renamed {
660 expected: res("github.com/acme/w"),
661 observed: res("github.com/acme/new-name")
662 }]
663 );
664
665 let imposter = RepoState::new(res("github.com/acme/w"), 10);
666 assert_eq!(
667 default_diff(&imposter, &want),
668 vec![Drift::Replaced {
669 expected: 9,
670 observed: 10
671 }]
672 );
673 }
674}