From c946e6cdd4cbd7a38910966feefad2d60bfefa24 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:41:21 +0100 Subject: [PATCH 01/11] 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 8fab91913..53874653e 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"; @@ -197,6 +198,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, Eq)] diff --git a/src/schema.rs b/src/schema.rs index d5076ba8e..937ac592e 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -846,6 +846,8 @@ pub(crate) struct Repo { pub environments: BTreeMap, #[serde(default)] pages: Option, + #[serde(default)] + pub custom_properties: BTreeMap, } impl Repo { diff --git a/src/static_api.rs b/src/static_api.rs index 21c2484b6..042aa5f89 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -234,6 +234,7 @@ impl<'a> Generator<'a> { pages: convert_pages(r)?, 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 f5e036f3e..d03d01b01 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -389,6 +389,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 76a1086de..4433f5169 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", @@ -138,7 +139,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 8015fd2e4..bb20c2f92 100644 --- a/tests/static-api/_expected/v1/repos/some_repo.json +++ b/tests/static-api/_expected/v1/repos/some_repo.json @@ -97,5 +97,6 @@ }, "archived": false, "private": false, - "auto_merge_enabled": true + "auto_merge_enabled": true, + "custom_properties": {} } \ No newline at end of file From 6358aa237dadbd212c2232ca0287ac87b64d56a1 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:03 +0100 Subject: [PATCH 02/11] 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 1dfb5c2df..a0a4c5ede 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, @@ -91,6 +91,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, @@ -749,6 +756,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 2fffa25af..1781d576b 100644 --- a/src/sync/github/api/write.rs +++ b/src/sync/github/api/write.rs @@ -5,8 +5,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; @@ -738,4 +738,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 ba7a2d911..57d53fe90 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -565,6 +565,14 @@ impl SyncGitHub { .collect(), pages: expected_repo.pages.clone(), 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(), })); } }; @@ -583,6 +591,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 pages_diff = self.diff_pages(expected_repo).await?; let old_settings = RepoSettings { @@ -631,6 +641,7 @@ impl SyncGitHub { environment_diffs, pages_diff, app_installation_diffs, + custom_property_diffs, })) } @@ -896,6 +907,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, @@ -1371,6 +1417,7 @@ struct CreateRepoDiff { environments: Vec<(String, rust_team_data::v1::Environment)>, pages: Option, app_installations: Vec, + custom_properties: Vec, } impl CreateRepoDiff { @@ -1406,6 +1453,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(()) } } @@ -1421,6 +1473,7 @@ impl std::fmt::Display for CreateRepoDiff { environments, pages, app_installations, + custom_properties, } = self; let RepoSettings { @@ -1471,6 +1524,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(()) } } @@ -1488,6 +1554,7 @@ struct UpdateRepoDiff { environment_diffs: Vec, pages_diff: Option, app_installation_diffs: Vec, + custom_property_diffs: Vec, } #[derive(Debug)] @@ -1567,6 +1634,7 @@ impl UpdateRepoDiff { environment_diffs, pages_diff, app_installation_diffs, + custom_property_diffs, } = self; settings_diff.0 == settings_diff.1 @@ -1576,6 +1644,7 @@ impl UpdateRepoDiff { && environment_diffs.is_empty() && pages_diff.is_none() && app_installation_diffs.is_empty() + && custom_property_diffs.is_empty() } fn can_be_modified(&self) -> bool { @@ -1616,6 +1685,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) => { @@ -1673,6 +1746,7 @@ impl std::fmt::Display for UpdateRepoDiff { environment_diffs, pages_diff, app_installation_diffs, + custom_property_diffs, } = self; writeln!(f, "📝 Editing repo '{org}/{name}':")?; @@ -1791,6 +1865,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(()) } } @@ -2389,6 +2470,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 dd7bc8545..737da868b 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -238,6 +238,7 @@ async fn repo_change_description() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -283,6 +284,7 @@ async fn repo_change_homepage() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -385,6 +387,7 @@ async fn repo_create() { environments: [], pages: None, app_installations: [], + custom_properties: [], }, ), ] @@ -610,6 +613,7 @@ async fn repo_add_member() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -667,6 +671,7 @@ async fn repo_change_member_permissions() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -718,6 +723,7 @@ async fn repo_remove_member() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -771,6 +777,7 @@ async fn repo_add_team() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -823,6 +830,7 @@ async fn repo_change_team_permissions() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -874,6 +882,7 @@ async fn repo_remove_team() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -916,6 +925,7 @@ async fn repo_archive_repo() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1045,6 +1055,7 @@ async fn repo_add_branch_protection() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1219,6 +1230,7 @@ async fn repo_update_branch_protection() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1275,6 +1287,7 @@ async fn repo_remove_branch_protection() { environment_diffs: [], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1419,6 +1432,7 @@ async fn repo_environment_create() { ], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1472,6 +1486,7 @@ async fn repo_environment_delete() { ], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1544,6 +1559,7 @@ async fn repo_environment_update() { ], pages_diff: None, app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1612,6 +1628,7 @@ async fn repo_environment_update_branches() { ], pages_diff: None, 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 d03d01b01..5d598c01e 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, @@ -761,6 +761,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 c32f5918566e3f2dc582252b2a707f849063227d Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:14 +0100 Subject: [PATCH 03/11] 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 d8610c571..eb6bf2f52 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -550,6 +550,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 ae5f8baf9287404d547f48df9e36bd58ad18bfe2 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Tue, 9 Jun 2026 00:51:24 +0100 Subject: [PATCH 04/11] 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 dd2d59254173e95a9ec086bcb8d609107468ae6a Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Sun, 14 Jun 2026 14:04:23 +0100 Subject: [PATCH 05/11] 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 | 34 +++++- .../_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, 188 insertions(+), 41 deletions(-) diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 53874653e..eef848abd 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"; @@ -198,8 +197,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, Eq)] diff --git a/src/static_api.rs b/src/static_api.rs index 042aa5f89..981ebef8f 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -234,7 +234,11 @@ impl<'a> Generator<'a> { pages: convert_pages(r)?, 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)?; @@ -493,7 +497,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 57d53fe90..02a3b64b9 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -925,13 +925,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(), @@ -939,6 +939,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) } @@ -2480,12 +2493,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 } @@ -2504,6 +2518,9 @@ impl std::fmt::Display for CustomPropertyDiff { self.name, old, new ) } + CustomPropertyDiffOperation::Delete(old) => { + writeln!(f, " Removing '{}' (was '{}')", self.name, old) + } } } } @@ -2512,6 +2529,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 737da868b..635636b2b 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -1634,3 +1634,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 5d598c01e..f380a5dc6 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -194,6 +194,15 @@ impl DataModel { if let Some(pages) = &repo.pages { org.repo_pages.insert(repo.name.clone(), pages.clone()); } + 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() { @@ -335,6 +344,8 @@ pub struct RepoData { pub environments: IndexMap, #[builder(default)] pub pages: Option, + #[builder(default)] + pub custom_properties: IndexMap, } impl RepoData { @@ -373,6 +384,7 @@ impl From for v1::Repo { branch_protections, environments, pages, + custom_properties, } = value; Self { org, @@ -389,7 +401,7 @@ impl From for v1::Repo { archived, private: false, auto_merge_enabled: allow_auto_merge, - custom_properties: Default::default(), + custom_properties, } } } @@ -440,6 +452,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 + } } pub fn legacy_pages(branch: &str, path: &str) -> Pages { @@ -763,10 +782,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( @@ -815,6 +839,8 @@ struct GithubOrg { repo_environments: HashMap>, // Repo name -> GitHub Pages settings repo_pages: 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 4433f5169..0cf21e873 100644 --- a/tests/static-api/_expected/v1/repos.json +++ b/tests/static-api/_expected/v1/repos.json @@ -143,4 +143,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 bb20c2f92..13afd39a0 100644 --- a/tests/static-api/_expected/v1/repos/some_repo.json +++ b/tests/static-api/_expected/v1/repos/some_repo.json @@ -99,4 +99,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 +} From fb3f054b31383285d16d7c73b5a77f81b38def85 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Mon, 22 Jun 2026 13:14:33 +0100 Subject: [PATCH 06/11] Switch custom property values from bool to String --- repos/rust-lang/crabwatch.toml | 2 +- rust_team_data/src/v1.rs | 2 +- src/schema.rs | 2 +- src/static_api.rs | 2 +- src/sync/github/mod.rs | 5 ++--- src/sync/github/tests/mod.rs | 4 ++-- src/sync/github/tests/test_utils.rs | 6 +++--- 7 files changed, 11 insertions(+), 12 deletions(-) diff --git a/repos/rust-lang/crabwatch.toml b/repos/rust-lang/crabwatch.toml index 48b59e2c7..b7b10e29c 100644 --- a/repos/rust-lang/crabwatch.toml +++ b/repos/rust-lang/crabwatch.toml @@ -12,5 +12,5 @@ ci-checks = ["CI"] required-approvals = 0 [custom-properties] -crabwatch = true +crabwatch = "true" diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index eef848abd..fb68011eb 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -197,7 +197,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, - pub custom_properties: IndexMap, + pub custom_properties: IndexMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/schema.rs b/src/schema.rs index 937ac592e..dbc75a999 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -847,7 +847,7 @@ pub(crate) struct Repo { #[serde(default)] pages: Option, #[serde(default)] - pub custom_properties: BTreeMap, + pub custom_properties: BTreeMap, } impl Repo { diff --git a/src/static_api.rs b/src/static_api.rs index 981ebef8f..296a8287b 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -237,7 +237,7 @@ impl<'a> Generator<'a> { custom_properties: r .custom_properties .iter() - .map(|(k, v)| (k.clone(), *v)) + .map(|(k, v)| (k.clone(), v.clone())) .collect(), }; diff --git a/src/sync/github/mod.rs b/src/sync/github/mod.rs index 02a3b64b9..5e01571e8 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -570,7 +570,7 @@ impl SyncGitHub { .iter() .map(|(name, value)| api::CustomPropertyValue { property_name: name.clone(), - value: Some(value.to_string()), + value: Some(value.clone()), }) .collect(), })); @@ -923,8 +923,7 @@ impl SyncGitHub { 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 expected = value.clone(); let actual = actual_by_name.get(name).and_then(|v| v.as_deref()); let operation = match actual { None => CustomPropertyDiffOperation::Create(expected), diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index 635636b2b..20e1d0ab9 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -1644,7 +1644,7 @@ async fn repo_add_custom_property() { model .get_repo("repo1") .custom_properties - .insert("crabwatch".to_string(), true); + .insert("crabwatch".to_string(), "true".to_string()); let diff = model.diff_repos(gh).await; insta::assert_debug_snapshot!(diff, @r#" @@ -1690,7 +1690,7 @@ async fn repo_add_custom_property() { #[tokio::test] async fn repo_remove_custom_property() { let mut model = DataModel::default(); - model.create_repo(RepoData::new("repo1").custom_property("crabwatch", true)); + model.create_repo(RepoData::new("repo1").custom_property("crabwatch", "true")); let gh = model.gh_model(); model.get_repo("repo1").custom_properties.clear(); diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index f380a5dc6..11c2fe877 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -345,7 +345,7 @@ pub struct RepoData { #[builder(default)] pub pages: Option, #[builder(default)] - pub custom_properties: IndexMap, + pub custom_properties: IndexMap, } impl RepoData { @@ -453,9 +453,9 @@ impl RepoDataBuilder { self } - pub fn custom_property(mut self, name: &str, value: bool) -> Self { + pub fn custom_property(mut self, name: &str, value: &str) -> Self { let mut custom_properties = self.custom_properties.clone().unwrap_or_default(); - custom_properties.insert(name.to_string(), value); + custom_properties.insert(name.to_string(), value.to_string()); self.custom_properties = Some(custom_properties); self } From b2b4e3d4cea1cab0a5a1cd8ae63dd9db1f1f883b Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Mon, 22 Jun 2026 21:54:13 +0100 Subject: [PATCH 07/11] Fix broken link in custom properties docs --- docs/toml-schema.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/toml-schema.md b/docs/toml-schema.md index eb6bf2f52..578a4d0e1 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -353,6 +353,7 @@ private-non-synced = false #### Bots Here are the available bots that can be added to the `bots` array: + - `datadog`: installs a GitHub App used by DataDog. Used for [CI Visibility](https://www.datadoghq.com/product/ci-cd-monitoring/). It has read access to organization members, and repository actions, administration, checks, code scanning alerts, commit statuses, contents, deployments, issues, pull requests, secret scanning alerts and secrets. @@ -556,7 +557,7 @@ branches = ["develop", "staging"] 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 +[Repository custom properties]: https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization ```toml # Repository custom properties (optional) @@ -568,6 +569,7 @@ 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. ```toml From 6be5375e8fc364cab7efcf0eb3e23d65a3e3788a Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 2 Jul 2026 19:27:52 +0100 Subject: [PATCH 08/11] Update snapshot tests after rebase --- src/sync/github/tests/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index 20e1d0ab9..723578715 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -499,6 +499,7 @@ async fn repo_pages_create() { ), ), app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -556,6 +557,7 @@ async fn repo_pages_update() { }, ), app_installation_diffs: [], + custom_property_diffs: [], }, ), ] @@ -1672,6 +1674,7 @@ async fn repo_add_custom_property() { branch_protection_diffs: [], ruleset_diffs: [], environment_diffs: [], + pages_diff: None, app_installation_diffs: [], custom_property_diffs: [ CustomPropertyDiff { @@ -1721,6 +1724,7 @@ async fn repo_remove_custom_property() { branch_protection_diffs: [], ruleset_diffs: [], environment_diffs: [], + pages_diff: None, app_installation_diffs: [], custom_property_diffs: [ CustomPropertyDiff { From 8f4656187e882dd6cd68be81a10630efd3b1490f Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 2 Jul 2026 23:32:10 +0100 Subject: [PATCH 09/11] Emit CannotApply diff when custom property isn't defined at org level --- src/sync/github/api/read.rs | 21 ++++++++++++ src/sync/github/mod.rs | 16 +++++++++ src/sync/github/tests/mod.rs | 53 +++++++++++++++++++++++++++++ src/sync/github/tests/test_utils.rs | 19 +++++++++++ 4 files changed, 109 insertions(+) diff --git a/src/sync/github/api/read.rs b/src/sync/github/api/read.rs index a0a4c5ede..f8297964b 100644 --- a/src/sync/github/api/read.rs +++ b/src/sync/github/api/read.rs @@ -98,6 +98,9 @@ pub(crate) trait GithubRead { repo: &str, ) -> anyhow::Result>; + /// Get the names of custom properties defined at the organization level + async fn org_property_names(&self, org: &str) -> anyhow::Result>; + async fn environment_branch_policies( &self, org: &str, @@ -776,6 +779,24 @@ impl GithubRead for GitHubApiRead { Ok(values) } + async fn org_property_names(&self, org: &str) -> anyhow::Result> { + #[derive(serde::Deserialize)] + struct PropertyDef { + property_name: String, + } + + // REST API endpoint for organization custom property schema + // https://docs.github.com/en/rest/orgs/custom-properties#get-all-custom-properties-for-an-organization + let defs: Vec = self + .client + .req(Method::GET, &GitHubUrl::orgs(org, "properties/schema")?)? + .send() + .await? + .json_annotated() + .await?; + Ok(defs.into_iter().map(|d| d.property_name).collect()) + } + async fn environment_branch_policies( &self, org: &str, diff --git a/src/sync/github/mod.rs b/src/sync/github/mod.rs index 5e01571e8..13c7ee8f6 100644 --- a/src/sync/github/mod.rs +++ b/src/sync/github/mod.rs @@ -915,6 +915,7 @@ impl SyncGitHub { .github .repo_custom_properties(&expected_repo.org, &expected_repo.name) .await?; + let org_property_names = self.github.org_property_names(&expected_repo.org).await?; let actual_by_name: HashMap> = actual .into_iter() @@ -923,6 +924,15 @@ impl SyncGitHub { let mut diffs = Vec::new(); for (name, value) in &expected_repo.custom_properties { + if !org_property_names.contains(name) { + diffs.push(CustomPropertyDiff { + name: name.clone(), + operation: CustomPropertyDiffOperation::CannotApply { + reason: format!("'{name}' is not defined at the org level"), + }, + }); + continue; + } let expected = value.clone(); let actual = actual_by_name.get(name).and_then(|v| v.as_deref()); let operation = match actual { @@ -2495,7 +2505,9 @@ impl CustomPropertyDiff { Some(v.clone()) } CustomPropertyDiffOperation::Delete(_) => None, + CustomPropertyDiffOperation::CannotApply { .. } => return Ok(()), }; + let property = api::CustomPropertyValue { property_name: self.name.clone(), value, @@ -2520,6 +2532,9 @@ impl std::fmt::Display for CustomPropertyDiff { CustomPropertyDiffOperation::Delete(old) => { writeln!(f, " Removing '{}' (was '{}')", self.name, old) } + CustomPropertyDiffOperation::CannotApply { reason } => { + writeln!(f, " Cannot apply '{}': {}", self.name, reason) + } } } } @@ -2529,6 +2544,7 @@ enum CustomPropertyDiffOperation { Create(String), Update(String, String), // old, new Delete(String), // previous value + CannotApply { reason: String }, } #[derive(Debug)] diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index 723578715..df4769aea 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -1640,6 +1640,7 @@ async fn repo_environment_update_branches() { #[tokio::test] async fn repo_add_custom_property() { let mut model = DataModel::default(); + model.define_org_property("rust-lang", "crabwatch"); model.create_repo(RepoData::new("repo1")); let gh = model.gh_model(); @@ -1739,3 +1740,55 @@ async fn repo_remove_custom_property() { ] "#); } +#[tokio::test] +async fn repo_custom_property_undefined_at_org() { + 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".to_string()); + + 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: [], + pages_diff: None, + app_installation_diffs: [], + custom_property_diffs: [ + CustomPropertyDiff { + name: "crabwatch", + operation: CannotApply { + reason: "'crabwatch' is not defined at the org level", + }, + }, + ], + }, + ), + ] + "#); +} diff --git a/src/sync/github/tests/test_utils.rs b/src/sync/github/tests/test_utils.rs index 11c2fe877..624c5a9f7 100644 --- a/src/sync/github/tests/test_utils.rs +++ b/src/sync/github/tests/test_utils.rs @@ -34,6 +34,7 @@ pub struct DataModel { teams: Vec, repos: Vec, config: Config, + org_property_names: HashMap>, } impl DataModel { @@ -72,6 +73,13 @@ impl DataModel { .expect("Repo not found") } + pub fn define_org_property(&mut self, org: &str, name: &str) { + self.org_property_names + .entry(org.to_string()) + .or_default() + .insert(name.to_string()); + } + pub fn add_allowed_org_member(&mut self, member: &str) { self.config.special_org_members.insert(member.to_string()); } @@ -205,6 +213,11 @@ impl DataModel { org.custom_properties.insert(repo.name.clone(), properties); } + for (org_name, prop_names) in &self.org_property_names { + let org = orgs.entry(org_name.clone()).or_default(); + org.org_property_names.extend(prop_names.iter().cloned()); + } + if orgs.is_empty() { orgs.insert(DEFAULT_ORG.to_string(), GithubOrg::default()); } @@ -793,6 +806,10 @@ impl GithubRead for GithubMock { .unwrap_or_default()) } + async fn org_property_names(&self, org: &str) -> anyhow::Result> { + Ok(self.get_org(org).org_property_names.clone()) + } + async fn repo_environments( &self, org: &str, @@ -841,6 +858,8 @@ struct GithubOrg { repo_pages: HashMap, // Repo name -> Vec custom_properties: HashMap>, + // Set of custom property names defined at the org level + org_property_names: HashSet, } #[derive(Clone)] From bd738383703c556f8d4462aa567fde3a21dccd06 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Fri, 3 Jul 2026 08:53:21 +0100 Subject: [PATCH 10/11] Note text-only custom property support in schema docs --- docs/toml-schema.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/toml-schema.md b/docs/toml-schema.md index 578a4d0e1..b6432ec7a 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -555,15 +555,15 @@ branches = ["develop", "staging"] [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. +Values are stored as strings. Only text-type custom properties are supported for now. [Repository custom properties]: https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization ```toml # Repository custom properties (optional) [custom-properties] -# Set a property name to a boolean value -crabwatch = true +# Set a property name to a string value +crabwatch = "true" ``` Properties set on GitHub but not declared here are left unchanged. From dc12bfbe7ef374232865259f218b92e547eefa04 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Fri, 3 Jul 2026 09:30:18 +0100 Subject: [PATCH 11/11] Update repo_pages_delete snapshot for custom_property_diffs --- src/sync/github/tests/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sync/github/tests/mod.rs b/src/sync/github/tests/mod.rs index df4769aea..4b11ca0a9 100644 --- a/src/sync/github/tests/mod.rs +++ b/src/sync/github/tests/mod.rs @@ -436,6 +436,7 @@ async fn repo_pages_delete() { ), ), app_installation_diffs: [], + custom_property_diffs: [], }, ), ]