Skip to content

Commit ead84d8

Browse files
committed
feat: implement rulesets
1 parent 00c975e commit ead84d8

File tree

11 files changed

+776
-14
lines changed

11 files changed

+776
-14
lines changed

config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,7 @@ members-without-zulip-id = [
8080
"therealprof",
8181
"zeenix"
8282
]
83+
84+
enable-rulesets-repos = [
85+
"rust-lang/bors"
86+
]

src/data.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ impl Data {
235235
Ok(sync_team::Config {
236236
special_org_members,
237237
independent_github_orgs: self.config.independent_github_orgs().clone(),
238+
enable_rulesets_repos: self.config.enable_rulesets_repos().clone(),
238239
})
239240
}
240241
}

src/schema.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub(crate) struct Config {
1616
// Use a BTreeSet for consistent ordering in tests
1717
special_org_members: BTreeSet<String>,
1818
members_without_zulip_id: BTreeSet<String>,
19+
#[serde(default)]
20+
enable_rulesets_repos: BTreeSet<String>,
1921
}
2022

2123
impl Config {
@@ -46,6 +48,10 @@ impl Config {
4648
pub(crate) fn members_without_zulip_id(&self) -> &BTreeSet<String> {
4749
&self.members_without_zulip_id
4850
}
51+
52+
pub(crate) fn enable_rulesets_repos(&self) -> &BTreeSet<String> {
53+
&self.enable_rulesets_repos
54+
}
4955
}
5056

5157
// This is an enum to allow two kinds of values for the email field:

sync-team/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ base64.workspace = true
1414
hyper-old-types.workspace = true
1515
serde_json.workspace = true
1616
secrecy.workspace = true
17+
indexmap.workspace = true
1718

1819
[dev-dependencies]
1920
indexmap.workspace = true

sync-team/src/github/api/mod.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,160 @@ pub(crate) struct RepoSettings {
479479
pub archived: bool,
480480
pub auto_merge_enabled: bool,
481481
}
482+
483+
/// GitHub Repository Ruleset
484+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
485+
pub(crate) struct Ruleset {
486+
#[serde(skip_serializing_if = "Option::is_none")]
487+
pub(crate) id: Option<i64>,
488+
pub(crate) name: String,
489+
pub(crate) target: RulesetTarget,
490+
pub(crate) source_type: RulesetSourceType,
491+
pub(crate) enforcement: RulesetEnforcement,
492+
#[serde(skip_serializing_if = "Option::is_none")]
493+
pub(crate) bypass_actors: Option<Vec<RulesetBypassActor>>,
494+
#[serde(default, skip_serializing_if = "Option::is_none")]
495+
pub(crate) conditions: Option<RulesetConditions>,
496+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
497+
pub(crate) rules: Vec<RulesetRule>,
498+
}
499+
500+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
501+
#[serde(rename_all = "lowercase")]
502+
pub(crate) enum RulesetTarget {
503+
Branch,
504+
Tag,
505+
}
506+
507+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
508+
#[serde(rename_all = "PascalCase")]
509+
pub(crate) enum RulesetSourceType {
510+
Repository,
511+
Organization,
512+
}
513+
514+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
515+
#[serde(rename_all = "lowercase")]
516+
pub(crate) enum RulesetEnforcement {
517+
Active,
518+
Disabled,
519+
Evaluate,
520+
}
521+
522+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
523+
pub(crate) struct RulesetBypassActor {
524+
pub(crate) actor_id: i64,
525+
pub(crate) actor_type: RulesetActorType,
526+
pub(crate) bypass_mode: RulesetBypassMode,
527+
}
528+
529+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
530+
#[serde(rename_all = "snake_case")]
531+
pub(crate) enum RulesetActorType {
532+
Integration,
533+
OrganizationAdmin,
534+
RepositoryRole,
535+
Team,
536+
}
537+
538+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
539+
#[serde(rename_all = "snake_case")]
540+
pub(crate) enum RulesetBypassMode {
541+
Always,
542+
PullRequest,
543+
}
544+
545+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
546+
pub(crate) struct RulesetConditions {
547+
#[serde(skip_serializing_if = "Option::is_none")]
548+
pub(crate) ref_name: Option<RulesetRefNameCondition>,
549+
}
550+
551+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
552+
pub(crate) struct RulesetRefNameCondition {
553+
pub(crate) include: Vec<String>,
554+
pub(crate) exclude: Vec<String>,
555+
}
556+
557+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
558+
#[serde(tag = "type", rename_all = "snake_case")]
559+
pub(crate) enum RulesetRule {
560+
Creation,
561+
Update,
562+
Deletion,
563+
RequiredLinearHistory,
564+
MergeQueue {
565+
parameters: MergeQueueParameters,
566+
},
567+
RequiredDeployments {
568+
parameters: RequiredDeploymentsParameters,
569+
},
570+
RequiredSignatures,
571+
PullRequest {
572+
parameters: PullRequestParameters,
573+
},
574+
RequiredStatusChecks {
575+
parameters: RequiredStatusChecksParameters,
576+
},
577+
NonFastForward,
578+
}
579+
580+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
581+
pub(crate) struct MergeQueueParameters {
582+
pub(crate) check_response_timeout_minutes: i32,
583+
pub(crate) grouping_strategy: MergeQueueGroupingStrategy,
584+
pub(crate) max_entries_to_build: i32,
585+
pub(crate) max_entries_to_merge: i32,
586+
pub(crate) merge_method: MergeQueueMergeMethod,
587+
pub(crate) min_entries_to_merge: i32,
588+
pub(crate) min_entries_to_merge_wait_minutes: i32,
589+
}
590+
591+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
592+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
593+
pub(crate) enum MergeQueueGroupingStrategy {
594+
Allgreen,
595+
Headgreen,
596+
}
597+
598+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
599+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
600+
pub(crate) enum MergeQueueMergeMethod {
601+
Merge,
602+
Squash,
603+
Rebase,
604+
}
605+
606+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
607+
pub(crate) struct RequiredDeploymentsParameters {
608+
pub(crate) required_deployment_environments: Vec<String>,
609+
}
610+
611+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
612+
pub(crate) struct PullRequestParameters {
613+
pub(crate) dismiss_stale_reviews_on_push: bool,
614+
pub(crate) require_code_owner_review: bool,
615+
pub(crate) require_last_push_approval: bool,
616+
pub(crate) required_approving_review_count: i32,
617+
pub(crate) required_review_thread_resolution: bool,
618+
}
619+
620+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
621+
pub(crate) struct RequiredStatusChecksParameters {
622+
#[serde(skip_serializing_if = "Option::is_none")]
623+
pub(crate) do_not_enforce_on_create: Option<bool>,
624+
pub(crate) required_status_checks: Vec<RequiredStatusCheck>,
625+
pub(crate) strict_required_status_checks_policy: bool,
626+
}
627+
628+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
629+
pub(crate) struct RequiredStatusCheck {
630+
pub(crate) context: String,
631+
#[serde(skip_serializing_if = "Option::is_none")]
632+
pub(crate) integration_id: Option<i64>,
633+
}
634+
635+
pub(crate) enum RulesetOp {
636+
CreateForRepo,
637+
UpdateRuleset(i64),
638+
}

sync-team/src/github/api/read.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::github::api::Ruleset;
12
use crate::github::api::{
23
BranchProtection, GraphNode, GraphNodes, GraphPageInfo, HttpClient, Login, Repo, RepoTeam,
34
RepoUser, Team, TeamMember, TeamRole, team_node_id, url::GitHubUrl, user_node_id,
@@ -59,6 +60,14 @@ pub(crate) trait GithubRead {
5960
org: &str,
6061
repo: &str,
6162
) -> anyhow::Result<HashMap<String, Environment>>;
63+
64+
/// Get rulesets for a repository
65+
/// Returns a vector of rulesets
66+
fn repo_rulesets(
67+
&self,
68+
org: &str,
69+
repo: &str,
70+
) -> anyhow::Result<Vec<crate::github::api::Ruleset>>;
6271
}
6372

6473
pub(crate) struct GitHubApiRead {
@@ -536,4 +545,26 @@ impl GithubRead for GitHubApiRead {
536545
})
537546
.collect()
538547
}
548+
549+
fn repo_rulesets(
550+
&self,
551+
org: &str,
552+
repo: &str,
553+
) -> anyhow::Result<Vec<crate::github::api::Ruleset>> {
554+
let mut rulesets: Vec<Ruleset> = Vec::new();
555+
556+
// REST API endpoint for rulesets
557+
// https://docs.github.com/en/rest/repos/rules#get-all-repository-rulesets
558+
// The API returns an array of rulesets directly, not wrapped in an object
559+
self.client.rest_paginated(
560+
&Method::GET,
561+
&GitHubUrl::repos(org, repo, "rulesets")?,
562+
|resp: Vec<Ruleset>| {
563+
rulesets.extend(resp);
564+
Ok(())
565+
},
566+
)?;
567+
568+
Ok(rulesets)
569+
}
539570
}

sync-team/src/github/api/write.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,4 +766,53 @@ impl GitHubWrite {
766766
}
767767
Ok(())
768768
}
769+
770+
/// Create or update a ruleset for a repository
771+
pub(crate) fn upsert_ruleset(
772+
&self,
773+
op: crate::github::api::RulesetOp,
774+
org: &str,
775+
repo: &str,
776+
ruleset: &crate::github::api::Ruleset,
777+
) -> anyhow::Result<()> {
778+
use crate::github::api::RulesetOp;
779+
780+
match op {
781+
RulesetOp::CreateForRepo => {
782+
debug!("Creating ruleset '{}' in '{}/{}'", ruleset.name, org, repo);
783+
if !self.dry_run {
784+
// REST API: POST /repos/{owner}/{repo}/rulesets
785+
// https://docs.github.com/en/rest/repos/rules#create-a-repository-ruleset
786+
let url = GitHubUrl::repos(org, repo, "rulesets")?;
787+
self.client.send(Method::POST, &url, ruleset)?;
788+
}
789+
}
790+
RulesetOp::UpdateRuleset(id) => {
791+
debug!(
792+
"Updating ruleset '{}' (id: {}) in '{}/{}'",
793+
ruleset.name, id, org, repo
794+
);
795+
if !self.dry_run {
796+
// REST API: PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}
797+
// https://docs.github.com/en/rest/repos/rules#update-a-repository-ruleset
798+
let url = GitHubUrl::repos(org, repo, &format!("rulesets/{}", id))?;
799+
self.client.send(Method::PUT, &url, ruleset)?;
800+
}
801+
}
802+
}
803+
Ok(())
804+
}
805+
806+
/// Delete a ruleset from a repository
807+
pub(crate) fn delete_ruleset(&self, org: &str, repo: &str, id: i64) -> anyhow::Result<()> {
808+
debug!("Deleting ruleset id {} from '{}/{}'", id, org, repo);
809+
if !self.dry_run {
810+
// REST API: DELETE /repos/{owner}/{repo}/rulesets/{ruleset_id}
811+
// https://docs.github.com/en/rest/repos/rules#delete-a-repository-ruleset
812+
let url = GitHubUrl::repos(org, repo, &format!("rulesets/{}", id))?;
813+
self.client
814+
.send(Method::DELETE, &url, &serde_json::json!({}))?;
815+
}
816+
Ok(())
817+
}
769818
}

0 commit comments

Comments
 (0)