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}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase", tag = "type")]
254#[non_exhaustive]
255pub enum Drift {
256 UnexpectedRole {
259 account: ForgeAccount,
261 observed: ForgeRole,
263 },
264 MissingRole {
266 account: ForgeAccount,
268 expected: ForgeRole,
270 },
271 RoleMismatch {
273 account: ForgeAccount,
275 expected: ForgeRole,
277 observed: ForgeRole,
279 },
280 ProtectionWeakened {
283 gaps: Vec<ProtectionGap>,
285 },
286 Renamed {
289 expected: Resource,
291 observed: Resource,
293 },
294 Replaced {
297 expected: u64,
299 observed: u64,
301 },
302 ArchiveMismatch {
304 expected: bool,
306 observed: bool,
308 },
309 VisibilityMismatch {
311 expected: Visibility,
313 observed: Visibility,
315 },
316 ReplanNeeded {
320 reason: String,
322 },
323}
324
325impl Drift {
326 pub fn is_critical(&self) -> bool {
331 matches!(
332 self,
333 Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
334 )
335 }
336}
337
338pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
340 if !observed.present {
341 let mut gaps = vec![ProtectionGap::Missing];
342 gaps.extend(observed.other_gaps.iter().cloned());
343 return gaps;
344 }
345 let mut gaps = Vec::new();
346 if !observed.enforced {
347 gaps.push(ProtectionGap::NotEnforced);
348 }
349 if !observed.covers_default_branch {
350 gaps.push(ProtectionGap::DefaultBranchNotCovered);
351 }
352 if !observed.requires_pull_request {
353 gaps.push(ProtectionGap::PullRequestNotRequired);
354 }
355 if !observed.required_checks.iter().any(|c| c == check) {
356 gaps.push(ProtectionGap::CheckNotRequired {
357 check: check.to_string(),
358 });
359 }
360 if !observed.blocks_force_push {
361 gaps.push(ProtectionGap::ForcePushAllowed);
362 }
363 if !observed.blocks_deletion {
364 gaps.push(ProtectionGap::DeletionAllowed);
365 }
366 if !observed.bypass_actors.is_empty() {
367 gaps.push(ProtectionGap::BypassActors {
368 actors: observed.bypass_actors.clone(),
369 });
370 }
371 gaps.extend(observed.other_gaps.iter().cloned());
372 gaps
373}
374
375pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
379 let mut drift = Vec::new();
380
381 if let Some(expected) = desired.forge_id
382 && expected != observed.forge_id
383 {
384 return vec![Drift::Replaced {
387 expected,
388 observed: observed.forge_id,
389 }];
390 }
391 if observed.resource != desired.resource {
392 drift.push(Drift::Renamed {
393 expected: desired.resource.clone(),
394 observed: observed.resource.clone(),
395 });
396 }
397 if observed.archived != desired.archived {
398 drift.push(Drift::ArchiveMismatch {
399 expected: desired.archived,
400 observed: observed.archived,
401 });
402 }
403 if let Some(expected) = desired.visibility
404 && expected != observed.visibility
405 {
406 drift.push(Drift::VisibilityMismatch {
407 expected,
408 observed: observed.visibility,
409 });
410 }
411 if let Some(check) = &desired.required_check {
412 let gaps = protection_gaps(&observed.protection, check);
413 if !gaps.is_empty() {
414 drift.push(Drift::ProtectionWeakened { gaps });
415 }
416 }
417
418 drift.extend(role_drift(observed, &desired.roles));
419 drift
420}
421
422fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
423 let mut drift = Vec::new();
424 for want in desired {
425 let have = observed
426 .collaborators
427 .iter()
428 .find(|c| c.account.id == want.account.id);
429 match have {
430 None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
431 account: want.account.clone(),
432 expected: want.role,
433 }),
434 Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
435 account: c.account.clone(),
436 observed: c.role,
437 }),
438 Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
439 account: c.account.clone(),
440 expected: want.role,
441 observed: c.role,
442 }),
443 _ => {}
444 }
445 }
446 for c in &observed.collaborators {
447 if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
448 drift.push(Drift::UnexpectedRole {
449 account: c.account.clone(),
450 observed: c.role,
451 });
452 }
453 }
454 drift
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::model::Collaborator;
461
462 fn res(s: &str) -> Resource {
463 Resource::parse(s).unwrap()
464 }
465
466 fn protected(check: &str) -> ProtectionState {
467 ProtectionState {
468 present: true,
469 enforced: true,
470 covers_default_branch: true,
471 requires_pull_request: true,
472 required_checks: vec![check.into()],
473 blocks_force_push: true,
474 blocks_deletion: true,
475 bypass_actors: vec![],
476 ..ProtectionState::default()
477 }
478 }
479
480 fn alice() -> ForgeAccount {
481 ForgeAccount::new(1, "alice")
482 }
483 fn bob() -> ForgeAccount {
484 ForgeAccount::new(2, "bob")
485 }
486
487 #[test]
488 fn a_matching_repo_has_no_drift() {
489 let mut state = RepoState::new(res("github.com/acme/w"), 9);
490 state.protection = protected("Verify commit trust");
491 state.collaborators = vec![
492 Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
493 Collaborator::invited(bob(), ForgeRole::Maintain),
494 ];
495 let mut want = Projection::new(res("github.com/acme/w"));
496 want.forge_id = Some(9);
497 want.required_check = Some("Verify commit trust".into());
498 want.roles = vec![
499 RoleAssignment::new(alice(), ForgeRole::Admin),
500 RoleAssignment::new(bob(), ForgeRole::Maintain),
501 ];
502 assert_eq!(default_diff(&state, &want), vec![]);
503 }
504
505 #[test]
506 fn role_drift_is_matched_by_id() {
507 let mut state = RepoState::new(res("github.com/acme/w"), 9);
508 state.collaborators = vec![
509 Collaborator::new(alice(), ForgeRole::Write),
510 Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
511 ];
512 let mut want = Projection::new(res("github.com/acme/w"));
513 want.roles = vec![
514 RoleAssignment::new(alice(), ForgeRole::Admin),
515 RoleAssignment::new(bob(), ForgeRole::Maintain),
516 ];
517 let drift = default_diff(&state, &want);
518 assert_eq!(
519 drift,
520 vec![
521 Drift::RoleMismatch {
522 account: alice(),
523 expected: ForgeRole::Admin,
524 observed: ForgeRole::Write
525 },
526 Drift::MissingRole {
527 account: bob(),
528 expected: ForgeRole::Maintain
529 },
530 Drift::UnexpectedRole {
531 account: ForgeAccount::new(3, "mallory"),
532 observed: ForgeRole::Admin
533 },
534 ]
535 );
536 assert!(!drift.iter().any(Drift::is_critical));
537 }
538
539 #[test]
540 fn weakened_protection_lists_every_gap() {
541 let mut state = RepoState::new(res("github.com/acme/w"), 9);
542 let mut p = protected("something else");
543 p.enforced = false;
544 p.blocks_force_push = false;
545 p.bypass_actors = vec!["OrganizationAdmin".into()];
546 state.protection = p;
547 let mut want = Projection::new(res("github.com/acme/w"));
548 want.required_check = Some("Verify commit trust".into());
549 let drift = default_diff(&state, &want);
550 assert_eq!(
551 drift,
552 vec![Drift::ProtectionWeakened {
553 gaps: vec![
554 ProtectionGap::NotEnforced,
555 ProtectionGap::CheckNotRequired {
556 check: "Verify commit trust".into()
557 },
558 ProtectionGap::ForcePushAllowed,
559 ProtectionGap::BypassActors {
560 actors: vec!["OrganizationAdmin".into()]
561 },
562 ]
563 }]
564 );
565 assert!(drift[0].is_critical());
566
567 state.protection = ProtectionState::default();
568 assert_eq!(
569 default_diff(&state, &want),
570 vec![Drift::ProtectionWeakened {
571 gaps: vec![ProtectionGap::Missing]
572 }]
573 );
574 }
575
576 #[test]
577 fn gaps_in_what_guards_the_workflow_are_reported_too() {
578 let mut state = RepoState::new(res("github.com/acme/w"), 9);
579 let unprotected = ProtectionGap::CheckSourceUnprotected {
580 detail: "the org ruleset is missing".into(),
581 };
582 state.protection = protected("Verify commit trust");
583 state.protection.other_gaps = vec![unprotected.clone()];
584 let mut want = Projection::new(res("github.com/acme/w"));
585 want.required_check = Some("Verify commit trust".into());
586 let drift = default_diff(&state, &want);
587 assert_eq!(
588 drift,
589 vec![Drift::ProtectionWeakened {
590 gaps: vec![unprotected.clone()]
591 }]
592 );
593 assert!(drift[0].is_critical());
594
595 state.protection = ProtectionState {
597 other_gaps: vec![unprotected.clone()],
598 ..ProtectionState::default()
599 };
600 assert_eq!(
601 protection_gaps(&state.protection, "Verify commit trust"),
602 vec![ProtectionGap::Missing, unprotected]
603 );
604 }
605
606 #[test]
607 fn rename_and_replacement_are_told_apart_by_forge_id() {
608 let state = RepoState::new(res("github.com/acme/new-name"), 9);
609 let mut want = Projection::new(res("github.com/acme/w"));
610 want.forge_id = Some(9);
611 assert_eq!(
612 default_diff(&state, &want),
613 vec![Drift::Renamed {
614 expected: res("github.com/acme/w"),
615 observed: res("github.com/acme/new-name")
616 }]
617 );
618
619 let imposter = RepoState::new(res("github.com/acme/w"), 10);
620 assert_eq!(
621 default_diff(&imposter, &want),
622 vec![Drift::Replaced {
623 expected: 9,
624 observed: 10
625 }]
626 );
627 }
628}