From 467dfe50f0221d11b540290178d9a55782b4009f Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:41:21 +0100 Subject: [PATCH 1/5] Add custom_properties field to Repo data types --- rust_team_data/src/v1.rs | 3 +++ src/schema.rs | 2 ++ src/static_api.rs | 1 + src/sync/github/tests/test_utils.rs | 1 + tests/static-api/_expected/v1/repos.json | 6 ++++-- tests/static-api/_expected/v1/repos/archived_repo.json | 3 ++- tests/static-api/_expected/v1/repos/some_repo.json | 3 ++- 7 files changed, 15 insertions(+), 4 deletions(-) diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 32953df86..5bd121b7d 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; pub static BASE_URL: &str = "https://team-api.infra.rust-lang.org/v1"; @@ -195,6 +196,8 @@ pub struct Repo { // Is the GitHub "Auto-merge" option enabled? // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request pub auto_merge_enabled: bool, + #[serde(default)] + pub custom_properties: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/schema.rs b/src/schema.rs index e7738b3c0..c7affdb34 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -844,6 +844,8 @@ pub(crate) struct Repo { pub crates_io: Vec, #[serde(default)] pub environments: BTreeMap, + #[serde(default)] + pub custom_properties: BTreeMap, } #[derive(serde::Deserialize, Debug, Clone, PartialEq)] diff --git a/src/static_api.rs b/src/static_api.rs index 309a60f2c..a6953c8be 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -233,6 +233,7 @@ impl<'a> Generator<'a> { }, archived, auto_merge_enabled: !managed_by_bors, + custom_properties: r.custom_properties.clone(), }; self.add(&format!("v1/repos/{}.json", r.name), &repo)?; diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index 711d6e606..f903adbb7 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -381,6 +381,7 @@ impl From for v1::Repo { archived, private: false, auto_merge_enabled: allow_auto_merge, + custom_properties: Default::default(), } } } diff --git a/tests/static-api/_expected/v1/repos.json b/tests/static-api/_expected/v1/repos.json index 3ed39f0ef..9fef623f1 100644 --- a/tests/static-api/_expected/v1/repos.json +++ b/tests/static-api/_expected/v1/repos.json @@ -37,7 +37,8 @@ "environments": {}, "archived": true, "private": false, - "auto_merge_enabled": true + "auto_merge_enabled": true, + "custom_properties": {} }, { "org": "test-org", @@ -131,7 +132,8 @@ }, "archived": false, "private": false, - "auto_merge_enabled": true + "auto_merge_enabled": true, + "custom_properties": {} } ] } \ No newline at end of file diff --git a/tests/static-api/_expected/v1/repos/archived_repo.json b/tests/static-api/_expected/v1/repos/archived_repo.json index f2631093b..d8a0d448b 100644 --- a/tests/static-api/_expected/v1/repos/archived_repo.json +++ b/tests/static-api/_expected/v1/repos/archived_repo.json @@ -35,5 +35,6 @@ "environments": {}, "archived": true, "private": false, - "auto_merge_enabled": true + "auto_merge_enabled": true, + "custom_properties": {} } \ No newline at end of file diff --git a/tests/static-api/_expected/v1/repos/some_repo.json b/tests/static-api/_expected/v1/repos/some_repo.json index 68532dc4f..81b790fb2 100644 --- a/tests/static-api/_expected/v1/repos/some_repo.json +++ b/tests/static-api/_expected/v1/repos/some_repo.json @@ -90,5 +90,6 @@ }, "archived": false, "private": false, - "auto_merge_enabled": true + "auto_merge_enabled": true, + "custom_properties": {} } \ No newline at end of file From a9dc00398be4895e9d055cb7e5df5d4fc5fda455 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:03 +0100 Subject: [PATCH 2/5] Sync custom properties through the GitHub API --- src/sync/github/api/mod.rs | 12 +++ src/sync/github/api/read.rs | 29 ++++++- src/sync/github/api/write.rs | 27 +++++- src/sync/github/mod.rs | 125 ++++++++++++++++++++++++++++ src/sync/github/tests/mod.rs | 17 ++++ src/sync/github/tests/test_utils.rs | 12 ++- 6 files changed, 217 insertions(+), 5 deletions(-) diff --git a/src/sync/github/api/mod.rs b/src/sync/github/api/mod.rs index 55d0601c1..646b35ae3 100644 --- a/src/sync/github/api/mod.rs +++ b/src/sync/github/api/mod.rs @@ -746,3 +746,15 @@ pub(crate) struct BranchPolicy { fn default_branch_policy_type() -> String { "branch".to_string() } + +/// A GitHub repository custom property. Values are strings even for booleans. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct CustomPropertyValue { + pub(crate) property_name: String, + pub(crate) value: Option, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct SetCustomPropertiesRequest { + pub(crate) properties: Vec, +} diff --git a/src/sync/github/api/read.rs b/src/sync/github/api/read.rs index 75331b4a9..0035218bb 100644 --- a/src/sync/github/api/read.rs +++ b/src/sync/github/api/read.rs @@ -1,6 +1,6 @@ use crate::sync::github::api; use crate::sync::github::api::url::TokenType; -use crate::sync::github::api::{BranchPolicy, Ruleset}; +use crate::sync::github::api::{BranchPolicy, CustomPropertyValue, Ruleset}; use crate::sync::github::api::{ BranchProtection, GraphNode, GraphNodes, GraphPageInfo, HttpClient, Login, OrgAppInstallation, Repo, RepoAppInstallation, RepoTeam, RepoUser, RestPaginatedError, Team, TeamMember, TeamRole, @@ -88,6 +88,13 @@ pub(crate) trait GithubRead { /// Returns a vector of rulesets async fn repo_rulesets(&self, org: &str, repo: &str) -> anyhow::Result>; + /// Get custom property values for a repository + async fn repo_custom_properties( + &self, + org: &str, + repo: &str, + ) -> anyhow::Result>; + async fn environment_branch_policies( &self, org: &str, @@ -712,6 +719,26 @@ impl GithubRead for GitHubApiRead { Ok(rulesets) } + async fn repo_custom_properties( + &self, + org: &str, + repo: &str, + ) -> anyhow::Result> { + // REST API endpoint for repository custom property values + // https://docs.github.com/en/rest/repos/custom-properties#get-all-custom-property-values-for-a-repository + let values: Vec = self + .client + .req( + Method::GET, + &GitHubUrl::repos(org, repo, "properties/values")?, + )? + .send() + .await? + .json_annotated() + .await?; + Ok(values) + } + async fn environment_branch_policies( &self, org: &str, diff --git a/src/sync/github/api/write.rs b/src/sync/github/api/write.rs index 236d4b96c..3f5ad626f 100644 --- a/src/sync/github/api/write.rs +++ b/src/sync/github/api/write.rs @@ -4,8 +4,8 @@ use std::collections::HashSet; use crate::sync::github::api::url::GitHubUrl; use crate::sync::github::api::{ - GitHubApiRead, GithubRead, HttpClient, Repo, RepoPermission, RepoSettings, Ruleset, RulesetOp, - Team, TeamPrivacy, TeamRole, allow_not_found, + CustomPropertyValue, GitHubApiRead, GithubRead, HttpClient, Repo, RepoPermission, RepoSettings, + Ruleset, RulesetOp, SetCustomPropertiesRequest, Team, TeamPrivacy, TeamRole, allow_not_found, }; use crate::sync::utils::ResponseExt; @@ -696,4 +696,27 @@ impl GitHubWrite { } Ok(()) } + + /// Set a custom property value on a repository + pub(crate) async fn set_custom_property( + &self, + org: &str, + repo: &str, + property: &CustomPropertyValue, + ) -> anyhow::Result<()> { + debug!( + "Setting custom property '{}' on '{}/{}'", + property.property_name, org, repo + ); + if !self.dry_run { + // REST API: PATCH /repos/{owner}/{repo}/properties/values + // https://docs.github.com/en/rest/repos/custom-properties#create-or-update-custom-property-values-for-a-repository + let url = GitHubUrl::repos(org, repo, "properties/values")?; + let body = SetCustomPropertiesRequest { + properties: vec![property.clone()], + }; + self.client.send(Method::PATCH, &url, &body).await?; + } + Ok(()) + } } diff --git a/src/sync/github/mod.rs b/src/sync/github/mod.rs index 2cae9bf17..2bdcf883f 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -564,6 +564,14 @@ impl SyncGitHub { .map(|(name, env)| (name.clone(), env.clone())) .collect(), app_installations: self.diff_app_installations(expected_repo, &[])?, + custom_properties: expected_repo + .custom_properties + .iter() + .map(|(name, value)| api::CustomPropertyValue { + property_name: name.clone(), + value: Some(value.to_string()), + }) + .collect(), })); } }; @@ -582,6 +590,8 @@ impl SyncGitHub { let ruleset_diffs = self.diff_rulesets(expected_repo).await?; + let custom_property_diffs = self.diff_custom_properties(expected_repo).await?; + let environment_diffs = self.diff_environments(expected_repo).await?; let old_settings = RepoSettings { description: actual_repo.description.clone(), @@ -628,6 +638,7 @@ impl SyncGitHub { ruleset_diffs, environment_diffs, app_installation_diffs, + custom_property_diffs, })) } @@ -870,6 +881,41 @@ impl SyncGitHub { Ok(ruleset_diffs) } + async fn diff_custom_properties( + &self, + expected_repo: &rust_team_data::v1::Repo, + ) -> anyhow::Result> { + let actual = self + .github + .repo_custom_properties(&expected_repo.org, &expected_repo.name) + .await?; + + let actual_by_name: HashMap> = actual + .into_iter() + .map(|p| (p.property_name, p.value)) + .collect(); + + let mut diffs = Vec::new(); + for (name, value) in &expected_repo.custom_properties { + // GitHub stores values as strings, even bools. + let expected = value.to_string(); + let operation = match actual_by_name.get(name) { + // Missing on the repo, or value is null. + None | Some(None) => CustomPropertyDiffOperation::Create(expected), + Some(Some(actual)) if actual != &expected => { + CustomPropertyDiffOperation::Update(actual.clone(), expected) + } + Some(Some(_)) => continue, + }; + diffs.push(CustomPropertyDiff { + name: name.clone(), + operation, + }); + } + + Ok(diffs) + } + fn diff_app_installations( &self, expected_repo: &rust_team_data::v1::Repo, @@ -1344,6 +1390,7 @@ struct CreateRepoDiff { rulesets: Vec, environments: Vec<(String, rust_team_data::v1::Environment)>, app_installations: Vec, + custom_properties: Vec, } impl CreateRepoDiff { @@ -1375,6 +1422,11 @@ impl CreateRepoDiff { installation.apply(sync, repo.repo_id, &self.org).await?; } + for property in &self.custom_properties { + sync.set_custom_property(&self.org, &self.name, property) + .await?; + } + Ok(()) } } @@ -1389,6 +1441,7 @@ impl std::fmt::Display for CreateRepoDiff { rulesets, environments, app_installations, + custom_properties, } = self; let RepoSettings { @@ -1435,6 +1488,19 @@ impl std::fmt::Display for CreateRepoDiff { write!(f, "{diff}")?; } + if !custom_properties.is_empty() { + writeln!(f, " Custom Properties:")?; + for property in custom_properties { + if let Some(value) = &property.value { + writeln!( + f, + " Setting '{}' = '{}'", + property.property_name, value + )?; + } + } + } + Ok(()) } } @@ -1451,6 +1517,7 @@ struct UpdateRepoDiff { ruleset_diffs: Vec, environment_diffs: Vec, app_installation_diffs: Vec, + custom_property_diffs: Vec, } #[derive(Debug)] @@ -1484,6 +1551,7 @@ impl UpdateRepoDiff { ruleset_diffs, environment_diffs, app_installation_diffs, + custom_property_diffs, } = self; settings_diff.0 == settings_diff.1 @@ -1492,6 +1560,7 @@ impl UpdateRepoDiff { && ruleset_diffs.is_empty() && environment_diffs.is_empty() && app_installation_diffs.is_empty() + && custom_property_diffs.is_empty() } fn can_be_modified(&self) -> bool { @@ -1532,6 +1601,10 @@ impl UpdateRepoDiff { ruleset.apply(sync, &self.org, &self.name).await?; } + for custom_property in &self.custom_property_diffs { + custom_property.apply(sync, &self.org, &self.name).await?; + } + for env_diff in &self.environment_diffs { match env_diff { EnvironmentDiff::Create(name, env) => { @@ -1584,6 +1657,7 @@ impl std::fmt::Display for UpdateRepoDiff { ruleset_diffs, environment_diffs, app_installation_diffs, + custom_property_diffs, } = self; writeln!(f, "📝 Editing repo '{org}/{name}':")?; @@ -1697,6 +1771,13 @@ impl std::fmt::Display for UpdateRepoDiff { } } + if !custom_property_diffs.is_empty() { + writeln!(f, " Custom Properties:")?; + for diff in custom_property_diffs { + write!(f, "{diff}")?; + } + } + Ok(()) } } @@ -2295,6 +2376,50 @@ enum RulesetDiffOperation { Delete(i64), } +#[derive(Debug)] +struct CustomPropertyDiff { + name: String, + operation: CustomPropertyDiffOperation, +} + +impl CustomPropertyDiff { + async fn apply(&self, sync: &GitHubWrite, org: &str, repo_name: &str) -> anyhow::Result<()> { + let value = match &self.operation { + CustomPropertyDiffOperation::Create(v) | CustomPropertyDiffOperation::Update(_, v) => { + v.clone() + } + }; + let property = api::CustomPropertyValue { + property_name: self.name.clone(), + value: Some(value), + }; + sync.set_custom_property(org, repo_name, &property).await + } +} + +impl std::fmt::Display for CustomPropertyDiff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.operation { + CustomPropertyDiffOperation::Create(value) => { + writeln!(f, " Setting '{}' = '{}'", self.name, value) + } + CustomPropertyDiffOperation::Update(old, new) => { + writeln!( + f, + " Updating '{}' from '{}' to '{}'", + self.name, old, new + ) + } + } + } +} + +#[derive(Debug)] +enum CustomPropertyDiffOperation { + Create(String), + Update(String, String), // old, new +} + #[derive(Debug)] enum TeamDiff { Create(CreateTeamDiff), diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index 4475aabde..28c903010 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -236,6 +236,7 @@ async fn repo_change_description() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -280,6 +281,7 @@ async fn repo_change_homepage() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -381,6 +383,7 @@ async fn repo_create() { ], environments: [], app_installations: [], + custom_properties: [], }, ), ] @@ -437,6 +440,7 @@ async fn repo_add_member() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -493,6 +497,7 @@ async fn repo_change_member_permissions() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -543,6 +548,7 @@ async fn repo_remove_member() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -595,6 +601,7 @@ async fn repo_add_team() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -646,6 +653,7 @@ async fn repo_change_team_permissions() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -696,6 +704,7 @@ async fn repo_remove_team() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -737,6 +746,7 @@ async fn repo_archive_repo() { ruleset_diffs: [], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -865,6 +875,7 @@ async fn repo_add_branch_protection() { ], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1038,6 +1049,7 @@ async fn repo_update_branch_protection() { ], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1093,6 +1105,7 @@ async fn repo_remove_branch_protection() { ], environment_diffs: [], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1236,6 +1249,7 @@ async fn repo_environment_create() { ), ], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1288,6 +1302,7 @@ async fn repo_environment_delete() { ), ], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1359,6 +1374,7 @@ async fn repo_environment_update() { ), ], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1426,6 +1442,7 @@ async fn repo_environment_update_branches() { }, ], app_installation_diffs: [], + custom_property_diffs: [], }, ), ] diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index f903adbb7..d230a6b13 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -12,8 +12,8 @@ use rust_team_data::v1::{ use crate::schema; use crate::sync::Config; use crate::sync::github::api::{ - BranchPolicy, BranchProtection, GithubRead, OrgAppInstallation, Repo, RepoAppInstallation, - RepoTeam, RepoUser, Ruleset, Team, TeamMember, TeamPrivacy, TeamRole, + BranchPolicy, BranchProtection, CustomPropertyValue, GithubRead, OrgAppInstallation, Repo, + RepoAppInstallation, RepoTeam, RepoUser, Ruleset, Team, TeamMember, TeamPrivacy, TeamRole, }; use crate::sync::github::{ OrgMembershipDiff, RepoDiff, SyncGitHub, TeamDiff, api, construct_ruleset, convert_permission, @@ -726,6 +726,14 @@ impl GithubRead for GithubMock { .unwrap_or_default()) } + async fn repo_custom_properties( + &self, + _org: &str, + _repo: &str, + ) -> anyhow::Result> { + Ok(vec![]) + } + async fn repo_environments( &self, org: &str, From 6d2bb0d6ed127a888579d44f436bdf2b346eaa7f Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:14 +0100 Subject: [PATCH 3/5] Document the custom-properties TOML field --- docs/toml-schema.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/toml-schema.md b/docs/toml-schema.md index 1ac0f4904..f16c1aebc 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -527,6 +527,23 @@ branches = ["develop", "staging"] # No branch or tag patterns specified - any branch or tag can deploy ``` +### Repository custom properties + +[Repository custom properties] are values set on a repository to opt it into org-wide tooling. The property must first be defined at the organization level. + +Only boolean values are supported. + +[Repository custom properties]: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/managing-custom-properties-for-a-repository + +```toml +# Repository custom properties (optional) +[custom-properties] +# Set a property name to a boolean value +crabwatch = true +``` + +Properties set on GitHub but not declared here are left unchanged. + ### Crates.io crate management Configure properties of crates.io crates that are deployed using Trusted Publishing from the given repository. From cdb81bc6f915da94efcebe2068bb16edf0522b81 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:24 +0100 Subject: [PATCH 4/5] Set crabwatch custom property on rust-lang/crabwatch --- repos/rust-lang/crabwatch.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/repos/rust-lang/crabwatch.toml b/repos/rust-lang/crabwatch.toml index 5ee5aeb95..48b59e2c7 100644 --- a/repos/rust-lang/crabwatch.toml +++ b/repos/rust-lang/crabwatch.toml @@ -11,3 +11,6 @@ pattern = "main" ci-checks = ["CI"] required-approvals = 0 +[custom-properties] +crabwatch = true + From 2c292de1bdff7ba8d261541060a5357d9d01df7c Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Sun, 14 Jun 2026 14:04:23 +0100 Subject: [PATCH 5/5] Apply PR review feedback --- rust_team_data/src/v1.rs | 4 +- src/static_api.rs | 8 +- src/sync/github/mod.rs | 34 ++++-- src/sync/github/tests/mod.rs | 101 ++++++++++++++++++ src/sync/github/tests/test_utils.rs | 35 +++++- .../_expected/v1/archived-teams.json | 2 +- .../_expected/v1/archived-teams/wg-test.json | 2 +- tests/static-api/_expected/v1/lists.json | 2 +- tests/static-api/_expected/v1/people.json | 2 +- .../v1/permissions/bors.crater.review.json | 2 +- .../v1/permissions/bors.crater.try.json | 2 +- .../v1/permissions/bors.crates_io.review.json | 2 +- .../v1/permissions/bors.crates_io.try.json | 2 +- .../_expected/v1/permissions/crater.json | 2 +- tests/static-api/_expected/v1/repos.json | 2 +- .../_expected/v1/repos/archived_repo.json | 2 +- .../_expected/v1/repos/some_repo.json | 2 +- tests/static-api/_expected/v1/rfcbot.json | 2 +- tests/static-api/_expected/v1/teams.json | 2 +- .../static-api/_expected/v1/teams/alumni.json | 2 +- tests/static-api/_expected/v1/teams/foo.json | 2 +- .../_expected/v1/teams/infra-admins.json | 2 +- .../_expected/v1/teams/leaderless.json | 2 +- .../v1/teams/leadership-council.json | 2 +- .../_expected/v1/teams/leads-permissions.json | 2 +- .../_expected/v1/teams/wg-test.json | 2 +- .../static-api/_expected/v1/zulip-groups.json | 2 +- tests/static-api/_expected/v1/zulip-map.json | 2 +- .../_expected/v1/zulip-streams.json | 2 +- 29 files changed, 189 insertions(+), 41 deletions(-) diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 5bd121b7d..9d6695252 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -1,6 +1,5 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; pub static BASE_URL: &str = "https://team-api.infra.rust-lang.org/v1"; @@ -196,8 +195,7 @@ pub struct Repo { // Is the GitHub "Auto-merge" option enabled? // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request pub auto_merge_enabled: bool, - #[serde(default)] - pub custom_properties: BTreeMap, + pub custom_properties: IndexMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/static_api.rs b/src/static_api.rs index a6953c8be..7f4bc76dd 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -233,7 +233,11 @@ impl<'a> Generator<'a> { }, archived, auto_merge_enabled: !managed_by_bors, - custom_properties: r.custom_properties.clone(), + custom_properties: r + .custom_properties + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(), }; self.add(&format!("v1/repos/{}.json", r.name), &repo)?; @@ -492,7 +496,7 @@ impl<'a> Generator<'a> { T: serde::Serialize + serde::de::DeserializeOwned + PartialEq, { info!("writing API object {path}..."); - let json = serde_json::to_string_pretty(obj)?; + let json = serde_json::to_string_pretty(obj)? + "\n"; self.write(path, json.as_bytes())?; let obj2: T = diff --git a/src/sync/github/mod.rs b/src/sync/github/mod.rs index 2bdcf883f..357fd9c1e 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -899,13 +899,13 @@ impl SyncGitHub { for (name, value) in &expected_repo.custom_properties { // GitHub stores values as strings, even bools. let expected = value.to_string(); - let operation = match actual_by_name.get(name) { - // Missing on the repo, or value is null. - None | Some(None) => CustomPropertyDiffOperation::Create(expected), - Some(Some(actual)) if actual != &expected => { - CustomPropertyDiffOperation::Update(actual.clone(), expected) + let actual = actual_by_name.get(name).and_then(|v| v.as_deref()); + let operation = match actual { + None => CustomPropertyDiffOperation::Create(expected), + Some(actual) if actual != expected => { + CustomPropertyDiffOperation::Update(actual.to_string(), expected) } - Some(Some(_)) => continue, + Some(_) => continue, }; diffs.push(CustomPropertyDiff { name: name.clone(), @@ -913,6 +913,19 @@ impl SyncGitHub { }); } + // Properties set on GitHub that no longer appear in the team config get removed. + for (name, actual) in &actual_by_name { + if expected_repo.custom_properties.contains_key(name) { + continue; + } + if let Some(actual) = actual { + diffs.push(CustomPropertyDiff { + name: name.clone(), + operation: CustomPropertyDiffOperation::Delete(actual.clone()), + }); + } + } + Ok(diffs) } @@ -2386,12 +2399,13 @@ impl CustomPropertyDiff { async fn apply(&self, sync: &GitHubWrite, org: &str, repo_name: &str) -> anyhow::Result<()> { let value = match &self.operation { CustomPropertyDiffOperation::Create(v) | CustomPropertyDiffOperation::Update(_, v) => { - v.clone() + Some(v.clone()) } + CustomPropertyDiffOperation::Delete(_) => None, }; let property = api::CustomPropertyValue { property_name: self.name.clone(), - value: Some(value), + value, }; sync.set_custom_property(org, repo_name, &property).await } @@ -2410,6 +2424,9 @@ impl std::fmt::Display for CustomPropertyDiff { self.name, old, new ) } + CustomPropertyDiffOperation::Delete(old) => { + writeln!(f, " Removing '{}' (was '{}')", self.name, old) + } } } } @@ -2418,6 +2435,7 @@ impl std::fmt::Display for CustomPropertyDiff { enum CustomPropertyDiffOperation { Create(String), Update(String, String), // old, new + Delete(String), // previous value } #[derive(Debug)] diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index 28c903010..881a76454 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -1448,3 +1448,104 @@ async fn repo_environment_update_branches() { ] "#); } + +#[tokio::test] +async fn repo_add_custom_property() { + let mut model = DataModel::default(); + model.create_repo(RepoData::new("repo1")); + let gh = model.gh_model(); + + model + .get_repo("repo1") + .custom_properties + .insert("crabwatch".to_string(), true); + + let diff = model.diff_repos(gh).await; + insta::assert_debug_snapshot!(diff, @r#" + [ + Update( + UpdateRepoDiff { + org: "rust-lang", + name: "repo1", + repo_id: 0, + settings_diff: ( + RepoSettings { + description: "", + homepage: None, + archived: false, + auto_merge_enabled: false, + }, + RepoSettings { + description: "", + homepage: None, + archived: false, + auto_merge_enabled: false, + }, + ), + permission_diffs: [], + branch_protection_diffs: [], + ruleset_diffs: [], + environment_diffs: [], + app_installation_diffs: [], + custom_property_diffs: [ + CustomPropertyDiff { + name: "crabwatch", + operation: Create( + "true", + ), + }, + ], + }, + ), + ] + "#); +} + +#[tokio::test] +async fn repo_remove_custom_property() { + let mut model = DataModel::default(); + model.create_repo(RepoData::new("repo1").custom_property("crabwatch", true)); + let gh = model.gh_model(); + + model.get_repo("repo1").custom_properties.clear(); + + let diff = model.diff_repos(gh).await; + insta::assert_debug_snapshot!(diff, @r#" + [ + Update( + UpdateRepoDiff { + org: "rust-lang", + name: "repo1", + repo_id: 0, + settings_diff: ( + RepoSettings { + description: "", + homepage: None, + archived: false, + auto_merge_enabled: false, + }, + RepoSettings { + description: "", + homepage: None, + archived: false, + auto_merge_enabled: false, + }, + ), + permission_diffs: [], + branch_protection_diffs: [], + ruleset_diffs: [], + environment_diffs: [], + app_installation_diffs: [], + custom_property_diffs: [ + CustomPropertyDiff { + name: "crabwatch", + operation: Delete( + "true", + ), + }, + ], + }, + ), + ] + "#); +} diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index d230a6b13..6305491bf 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -190,6 +190,16 @@ impl DataModel { repo.environments.clone().into_iter().collect(); org.repo_environments .insert(repo.name.clone(), environments); + + let properties: Vec = repo + .custom_properties + .iter() + .map(|(name, value)| CustomPropertyValue { + property_name: name.clone(), + value: Some(value.to_string()), + }) + .collect(); + org.custom_properties.insert(repo.name.clone(), properties); } if orgs.is_empty() { @@ -329,6 +339,8 @@ pub struct RepoData { pub branch_protections: Vec, #[builder(default)] pub environments: IndexMap, + #[builder(default)] + pub custom_properties: IndexMap, } impl RepoData { @@ -366,6 +378,7 @@ impl From for v1::Repo { allow_auto_merge, branch_protections, environments, + custom_properties, } = value; Self { org, @@ -381,7 +394,7 @@ impl From for v1::Repo { archived, private: false, auto_merge_enabled: allow_auto_merge, - custom_properties: Default::default(), + custom_properties, } } } @@ -432,6 +445,13 @@ impl RepoDataBuilder { self.environments = Some(environments); self } + + pub fn custom_property(mut self, name: &str, value: bool) -> Self { + let mut custom_properties = self.custom_properties.clone().unwrap_or_default(); + custom_properties.insert(name.to_string(), value); + self.custom_properties = Some(custom_properties); + self + } } #[derive(Clone)] @@ -728,10 +748,15 @@ impl GithubRead for GithubMock { async fn repo_custom_properties( &self, - _org: &str, - _repo: &str, + org: &str, + repo: &str, ) -> anyhow::Result> { - Ok(vec![]) + Ok(self + .get_org(org) + .custom_properties + .get(repo) + .cloned() + .unwrap_or_default()) } async fn repo_environments( @@ -778,6 +803,8 @@ struct GithubOrg { rulesets: HashMap>, // Repo name -> HashMap repo_environments: HashMap>, + // Repo name -> Vec + custom_properties: HashMap>, } #[derive(Clone)] diff --git a/tests/static-api/_expected/v1/archived-teams.json b/tests/static-api/_expected/v1/archived-teams.json index 2ed1008c4..6f5f71c9b 100644 --- a/tests/static-api/_expected/v1/archived-teams.json +++ b/tests/static-api/_expected/v1/archived-teams.json @@ -36,4 +36,4 @@ } ] } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/archived-teams/wg-test.json b/tests/static-api/_expected/v1/archived-teams/wg-test.json index dc09d1d9a..1ef74c42b 100644 --- a/tests/static-api/_expected/v1/archived-teams/wg-test.json +++ b/tests/static-api/_expected/v1/archived-teams/wg-test.json @@ -34,4 +34,4 @@ "description": "Convener" } ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/lists.json b/tests/static-api/_expected/v1/lists.json index b057623ca..7402a9185 100644 --- a/tests/static-api/_expected/v1/lists.json +++ b/tests/static-api/_expected/v1/lists.json @@ -16,4 +16,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/people.json b/tests/static-api/_expected/v1/people.json index 45e1c6e57..037f7d030 100644 --- a/tests/static-api/_expected/v1/people.json +++ b/tests/static-api/_expected/v1/people.json @@ -49,4 +49,4 @@ "github_sponsors": false } } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/permissions/bors.crater.review.json b/tests/static-api/_expected/v1/permissions/bors.crater.review.json index bdbd2b4a2..10ad15258 100644 --- a/tests/static-api/_expected/v1/permissions/bors.crater.review.json +++ b/tests/static-api/_expected/v1/permissions/bors.crater.review.json @@ -3,4 +3,4 @@ "github_users": [], "github_ids": [], "discord_ids": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/permissions/bors.crater.try.json b/tests/static-api/_expected/v1/permissions/bors.crater.try.json index 12466bf70..7796f785a 100644 --- a/tests/static-api/_expected/v1/permissions/bors.crater.try.json +++ b/tests/static-api/_expected/v1/permissions/bors.crater.try.json @@ -31,4 +31,4 @@ 1, 2 ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/permissions/bors.crates_io.review.json b/tests/static-api/_expected/v1/permissions/bors.crates_io.review.json index ac3df8c93..d882191aa 100644 --- a/tests/static-api/_expected/v1/permissions/bors.crates_io.review.json +++ b/tests/static-api/_expected/v1/permissions/bors.crates_io.review.json @@ -38,4 +38,4 @@ 1, 2 ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/permissions/bors.crates_io.try.json b/tests/static-api/_expected/v1/permissions/bors.crates_io.try.json index ac3df8c93..d882191aa 100644 --- a/tests/static-api/_expected/v1/permissions/bors.crates_io.try.json +++ b/tests/static-api/_expected/v1/permissions/bors.crates_io.try.json @@ -38,4 +38,4 @@ 1, 2 ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/permissions/crater.json b/tests/static-api/_expected/v1/permissions/crater.json index 12466bf70..7796f785a 100644 --- a/tests/static-api/_expected/v1/permissions/crater.json +++ b/tests/static-api/_expected/v1/permissions/crater.json @@ -31,4 +31,4 @@ 1, 2 ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/repos.json b/tests/static-api/_expected/v1/repos.json index 9fef623f1..f63ab1bb2 100644 --- a/tests/static-api/_expected/v1/repos.json +++ b/tests/static-api/_expected/v1/repos.json @@ -136,4 +136,4 @@ "custom_properties": {} } ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/repos/archived_repo.json b/tests/static-api/_expected/v1/repos/archived_repo.json index d8a0d448b..2fe567ad0 100644 --- a/tests/static-api/_expected/v1/repos/archived_repo.json +++ b/tests/static-api/_expected/v1/repos/archived_repo.json @@ -37,4 +37,4 @@ "private": false, "auto_merge_enabled": true, "custom_properties": {} -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/repos/some_repo.json b/tests/static-api/_expected/v1/repos/some_repo.json index 81b790fb2..e5a4b2192 100644 --- a/tests/static-api/_expected/v1/repos/some_repo.json +++ b/tests/static-api/_expected/v1/repos/some_repo.json @@ -92,4 +92,4 @@ "private": false, "auto_merge_enabled": true, "custom_properties": {} -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/rfcbot.json b/tests/static-api/_expected/v1/rfcbot.json index 2bc1d98ad..b69297194 100644 --- a/tests/static-api/_expected/v1/rfcbot.json +++ b/tests/static-api/_expected/v1/rfcbot.json @@ -8,4 +8,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams.json b/tests/static-api/_expected/v1/teams.json index f271016fd..c7a00d09f 100644 --- a/tests/static-api/_expected/v1/teams.json +++ b/tests/static-api/_expected/v1/teams.json @@ -218,4 +218,4 @@ } ] } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/alumni.json b/tests/static-api/_expected/v1/teams/alumni.json index 1d2ce62b0..3e7156535 100644 --- a/tests/static-api/_expected/v1/teams/alumni.json +++ b/tests/static-api/_expected/v1/teams/alumni.json @@ -14,4 +14,4 @@ "github": null, "website_data": null, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/foo.json b/tests/static-api/_expected/v1/teams/foo.json index 345c5e151..b48f2aedf 100644 --- a/tests/static-api/_expected/v1/teams/foo.json +++ b/tests/static-api/_expected/v1/teams/foo.json @@ -50,4 +50,4 @@ "weight": 1000 }, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/infra-admins.json b/tests/static-api/_expected/v1/teams/infra-admins.json index db547f0f5..6ebe6e7da 100644 --- a/tests/static-api/_expected/v1/teams/infra-admins.json +++ b/tests/static-api/_expected/v1/teams/infra-admins.json @@ -14,4 +14,4 @@ "github": null, "website_data": null, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/leaderless.json b/tests/static-api/_expected/v1/teams/leaderless.json index 264d1faa4..e443683d6 100644 --- a/tests/static-api/_expected/v1/teams/leaderless.json +++ b/tests/static-api/_expected/v1/teams/leaderless.json @@ -24,4 +24,4 @@ "weight": 0 }, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/leadership-council.json b/tests/static-api/_expected/v1/teams/leadership-council.json index 4674b6dec..8e0bf2ba7 100644 --- a/tests/static-api/_expected/v1/teams/leadership-council.json +++ b/tests/static-api/_expected/v1/teams/leadership-council.json @@ -16,4 +16,4 @@ "weight": 0 }, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/leads-permissions.json b/tests/static-api/_expected/v1/teams/leads-permissions.json index 22ed65c8e..8866f7065 100644 --- a/tests/static-api/_expected/v1/teams/leads-permissions.json +++ b/tests/static-api/_expected/v1/teams/leads-permissions.json @@ -36,4 +36,4 @@ "weight": 0 }, "roles": [] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/teams/wg-test.json b/tests/static-api/_expected/v1/teams/wg-test.json index 1ae2a1ca9..0c36df791 100644 --- a/tests/static-api/_expected/v1/teams/wg-test.json +++ b/tests/static-api/_expected/v1/teams/wg-test.json @@ -44,4 +44,4 @@ "description": "Convener" } ] -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/zulip-groups.json b/tests/static-api/_expected/v1/zulip-groups.json index 2f66a6108..8b0d7456b 100644 --- a/tests/static-api/_expected/v1/zulip-groups.json +++ b/tests/static-api/_expected/v1/zulip-groups.json @@ -12,4 +12,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/zulip-map.json b/tests/static-api/_expected/v1/zulip-map.json index fd8ed9ca1..f5543122b 100644 --- a/tests/static-api/_expected/v1/zulip-map.json +++ b/tests/static-api/_expected/v1/zulip-map.json @@ -5,4 +5,4 @@ "1234": 0, "4321": 0 } -} \ No newline at end of file +} diff --git a/tests/static-api/_expected/v1/zulip-streams.json b/tests/static-api/_expected/v1/zulip-streams.json index f87ddad63..d2a81e0fa 100644 --- a/tests/static-api/_expected/v1/zulip-streams.json +++ b/tests/static-api/_expected/v1/zulip-streams.json @@ -12,4 +12,4 @@ ] } } -} \ No newline at end of file +}