Skip to content

Commit 5d9fa42

Browse files
authored
Merge branch 'main-enterprise' into fix/paginate-variables-narrow
2 parents 384ebe9 + 0cc709f commit 5d9fa42

13 files changed

Lines changed: 1080 additions & 363 deletions

File tree

docs/github-settings/4. teams.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,33 @@ teams:
4848
permission: maintain
4949
```
5050
51+
</td></tr>
52+
<tr><td>
53+
<p>&emsp;<code>exclude</code><span style="color:gray;">&emsp;<i>array</i>&emsp;</span></p>
54+
<p>Exclude a list of repos for this team. The team is applied to every repo in scope except those whose names match one of these glob patterns.</p>
55+
</td><td style="vertical-align:top">
56+
57+
```yaml
58+
teams:
59+
- name: SuperFriends
60+
permission: maintain
61+
exclude:
62+
- secret-repo
63+
```
64+
65+
</td></tr>
66+
<tr><td>
67+
<p>&emsp;<code>include</code><span style="color:gray;">&emsp;<i>array</i>&emsp;</span></p>
68+
<p>Include a list of repos for this team. The team is applied only to repos whose names match one of these glob patterns.</p>
69+
</td><td style="vertical-align:top">
70+
71+
```yaml
72+
teams:
73+
- name: SuperFriends
74+
permission: maintain
75+
include:
76+
- public-*
77+
```
78+
5179
</td></tr>
5280
</table>

docs/sample-settings/settings.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ teams:
161161
- name: globalteam
162162
permission: push
163163
visibility: closed
164+
- name: docs-team
165+
permission: pull
166+
# You can include a list of repos for this team and only those repos would have this team
167+
include:
168+
- actions-demo
169+
- name: ops-team
170+
permission: push
171+
# You can exclude a list of repos for this team and all repos except these repos would have this team
172+
exclude:
173+
- actions-demo
164174

165175
# Branch protection rules
166176
# See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2026-03-10#update-branch-protection for available options

lib/settings.js

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)