@@ -314,7 +314,7 @@ ${this.results.reduce((x, y) => {
314314 }
315315
316316 async loadConfigs ( repo ) {
317- this . subOrgConfigs = await this . getSubOrgConfigs ( )
317+ this . subOrgConfigs = await this . getSubOrgConfigs ( repo )
318318 this . repoConfigs = await this . getRepoConfigs ( repo )
319319 }
320320
@@ -329,7 +329,7 @@ ${this.results.reduce((x, y) => {
329329 }
330330
331331 async updateRepos ( repo ) {
332- this . subOrgConfigs = this . subOrgConfigs || await this . getSubOrgConfigs ( )
332+ this . subOrgConfigs = this . subOrgConfigs || await this . getSubOrgConfigs ( repo )
333333 // Create a new object to avoid mutating the shared this.config.repository
334334 // This prevents race conditions when multiple repos are processed concurrently via Promise.all
335335 let repoConfig = this . config . repository
@@ -753,13 +753,21 @@ ${this.results.reduce((x, y) => {
753753 * @param params Params to fetch the file with
754754 * @return The parsed YAML file
755755 */
756- async getSubOrgConfigs ( ) {
756+ async getSubOrgConfigs ( repo ) {
757757 try {
758758 // Get all suborg configs even though we might be here becuase of a suborg config change
759759 // we will filter them out if request is due to a suborg config change
760760 const overridePaths = await this . getSubOrgConfigMap ( )
761761 const subOrgConfigs = { }
762762
763+ // When syncing a single repo (and not processing a suborg config change),
764+ // resolve suborg membership by inspecting only this repo's teams/custom
765+ // properties instead of enumerating every repo of every suborg across the
766+ // whole org. See getSubOrgConfigsForRepo.
767+ if ( repo && ! this . subOrgConfigMap ) {
768+ return await this . getSubOrgConfigsForRepo ( repo , overridePaths || [ ] )
769+ }
770+
763771 for ( const override of overridePaths ) {
764772 const data = await this . loadYaml ( override . path )
765773 this . log . debug ( `data = ${ JSON . stringify ( data ) } ` )
@@ -830,6 +838,92 @@ ${this.results.reduce((x, y) => {
830838 }
831839 }
832840
841+ /**
842+ * Repo-scoped variant of suborg config resolution.
843+ *
844+ * Instead of expanding every suborg's `suborgteams`/`suborgproperties` into the
845+ * full set of matching repos across the org (many paginated, org-wide API calls),
846+ * this inspects only the repo being synced: it fetches the repo's own teams and
847+ * custom property values at most once each (and only if some suborg config needs
848+ * them), then matches locally. Result: a single-repo event costs at most one
849+ * `GET /repos/{owner}/{repo}/teams` and one `GET /repos/{owner}/{repo}/properties/values`
850+ * regardless of how many suborgs/teams/properties are defined.
851+ *
852+ * @param {* } repo the repo being synced ({ owner, repo })
853+ * @param {* } overridePaths list of suborg config files ({ name, path })
854+ * @returns subOrgConfigs keyed by repo name for the suborg (if any) this repo belongs to
855+ */
856+ async getSubOrgConfigsForRepo ( repo , overridePaths ) {
857+ const subOrgConfigs = { }
858+ const repoName = repo . repo
859+
860+ // Lazily fetched and cached for this repo; only queried when a suborg config
861+ // actually references teams/properties.
862+ let repoTeamSlugs
863+ let repoProperties
864+
865+ for ( const override of overridePaths ) {
866+ const data = await this . loadYaml ( override . path )
867+ this . log . debug ( `data = ${ JSON . stringify ( data ) } ` )
868+ if ( ! data ) { continue }
869+
870+ let matched = false
871+
872+ if ( data . suborgrepos ) {
873+ matched = data . suborgrepos . some ( pattern => new Glob ( pattern ) . test ( repoName ) )
874+ }
875+
876+ if ( ! matched && data . suborgteams ) {
877+ if ( repoTeamSlugs === undefined ) {
878+ repoTeamSlugs = ( await this . getReposTeams ( repo ) ) . map ( team => team . slug )
879+ }
880+ matched = data . suborgteams . some ( teamslug => repoTeamSlugs . includes ( teamslug ) )
881+ }
882+
883+ if ( ! matched && data . suborgproperties ) {
884+ if ( repoProperties === undefined ) {
885+ repoProperties = await this . getRepoCustomPropertyValues ( repo )
886+ }
887+ matched = this . repoMatchesProperties ( repoProperties , data . suborgproperties )
888+ }
889+
890+ if ( matched ) {
891+ this . storeSubOrgConfigIfNoConflicts ( subOrgConfigs , override . path , repoName , data )
892+ }
893+ }
894+
895+ return subOrgConfigs
896+ }
897+
898+ /**
899+ * Returns true if the repo's custom property values satisfy any of the suborg
900+ * property filters. Mirrors getRepositoriesByProperty by only considering the
901+ * first key/value of each filter entry (e.g. `- EDP: true`). Values are compared
902+ * as strings so YAML booleans/numbers match the API's string representation, and
903+ * multi-select property values (arrays) match if they contain the expected value.
904+ */
905+ repoMatchesProperties ( repoProperties , subOrgProperties ) {
906+ // Property names are case-insensitive in GitHub (the org-wide `props.` search
907+ // relied on this, and the custom_properties plugin lowercases names for the same
908+ // reason), so normalize names to lowercase on both sides before comparing.
909+ const propertyValues = new Map (
910+ ( repoProperties || [ ] ) . map ( property => [ String ( property . property_name ) . toLowerCase ( ) , property . value ] )
911+ )
912+ return subOrgProperties . some ( filter => {
913+ const [ name ] = Object . keys ( filter )
914+ const expected = filter [ name ]
915+ const key = String ( name ) . toLowerCase ( )
916+ if ( ! propertyValues . has ( key ) ) {
917+ return false
918+ }
919+ const actual = propertyValues . get ( key )
920+ if ( Array . isArray ( actual ) ) {
921+ return actual . map ( String ) . includes ( String ( expected ) )
922+ }
923+ return String ( actual ) === String ( expected )
924+ } )
925+ }
926+
833927 storeSubOrgConfigIfNoConflicts ( subOrgConfigs , overridePath , repoName , data ) {
834928 const existingConfigForRepo = subOrgConfigs [ repoName ]
835929 if ( existingConfigForRepo && existingConfigForRepo . source !== overridePath ) {
@@ -933,6 +1027,35 @@ ${this.results.reduce((x, y) => {
9331027 return this . github . paginate ( options )
9341028 }
9351029
1030+ // Repo-scoped inverse of getReposForTeam: the teams a single repo belongs to.
1031+ async getReposTeams ( repo ) {
1032+ const options = this . github . rest . repos . listTeams . endpoint . merge ( {
1033+ owner : repo . owner ,
1034+ repo : repo . repo ,
1035+ per_page : 100
1036+ } )
1037+ return this . github . paginate ( options )
1038+ }
1039+
1040+ // Repo-scoped inverse of getRepositoriesByProperty: the custom property values
1041+ // set on a single repo. Returns an array of { property_name, value }.
1042+ // Uses the same typed endpoint as the custom_properties plugin
1043+ // (customPropertiesForReposGetRepositoryValues).
1044+ async getRepoCustomPropertyValues ( repo ) {
1045+ const endpoint = this . github . rest . repos . customPropertiesForReposGetRepositoryValues
1046+ // Octokit renamed this method (it used to be `getCustomPropertiesValues`). Passing an
1047+ // undefined route to paginate() does not throw - it silently requests the API root and
1048+ // returns junk, which makes every suborgproperties match fail with no error. Fail loudly.
1049+ if ( typeof endpoint !== 'function' ) {
1050+ throw new Error ( 'octokit.rest.repos.customPropertiesForReposGetRepositoryValues is not available; cannot resolve suborgproperties' )
1051+ }
1052+ return this . github . paginate ( endpoint , {
1053+ owner : repo . owner ,
1054+ repo : repo . repo ,
1055+ per_page : 100
1056+ } )
1057+ }
1058+
9361059 async getRepositoriesByProperty ( organizationName , propertyFilter ) {
9371060 if ( ! organizationName || ! propertyFilter ) {
9381061 throw new Error ( 'Organization name and property filter are required' )
0 commit comments