Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 125 additions & 20 deletions lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ class Settings {
settings.subOrgConfigs = await settings.getSubOrgConfigs()
settings.trackChangedReposFromSubOrgConfigs()

// Sync org-level rulesets once before processing repos/suborgs
await settings.syncOrgLevelRulesets()

// Identify repos removed from suborg targeting due to targeting rule
// changes in the suborg config file. These repos need processing so
// their suborg-applied settings (e.g. rulesets) are cleaned up.
Expand Down Expand Up @@ -744,7 +747,9 @@ class Settings {
settings.subOrgConfigMap = [suborg]
settings.suborgChange = !!suborg
await settings.loadConfigs()
await settings.updateAll()
await settings.eachRepositoryRepos(settings.github, settings.log).then(res => {
settings.appendToResults(res)
})
}

// Sync app installations for affected apps (delta mode)
Expand Down Expand Up @@ -1125,7 +1130,10 @@ class Settings {
const pluginSection = res.plugin ? res.plugin.toLowerCase() : null

if (isOrgLevel && pluginSection === 'rulesets') {
// Org-level rulesets: keep only rulesets whose definition changed.
// Org-level rulesets: keep if top-level rulesets changed OR any suborg changed
if (res.fromSubOrg && this.changedRepoNames && this.changedRepoNames.size > 0) {
return true
}
const changedNames = getChangedEntryNames(this.baseConfig.rulesets, this.config.rulesets)
if (changedNames.size === 0) return false
const filtered = filterActionByChangedNames(res.action, changedNames)
Expand Down Expand Up @@ -1514,24 +1522,6 @@ class Settings {
const stripMap = this.computeStripMap()
const additiveSet = this.normalizeAdditivePlugins()

const rulesetsConfig = applyCentralizedBypassActors(this.config.rulesets, this.config.centralized_ruleset_bypass_actors)
if (rulesetsConfig) {
if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) {
this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin")
this.emitDisableSkip('rulesets')
} else {
const RulesetsPlugin = Settings.PLUGINS.rulesets
const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG)
rulesetsPlugin.additive = additiveSet.has('rulesets')
await rulesetsPlugin.sync().then(res => {
if (this.nop && Array.isArray(res)) {
res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` })
}
this.appendToResults(res)
})
}
}

const customRepositoryRolesConfig = this.config.custom_repository_roles
if (customRepositoryRolesConfig) {
if (this.isPluginDisabledAnywhere(stripMap, 'custom_repository_roles')) {
Expand Down Expand Up @@ -2156,14 +2146,111 @@ class Settings {
}
}

// Resolves whether a suborg's rulesets should be applied as one shared
// org-level ruleset ('org') or per-repo ('repo', the original behavior).
// Precedence: the suborg's own `ruleset_scope` wins if set; otherwise falls
// back to the org-wide default `ruleset_scope` in org-settings.yml (admin-
// team controlled); otherwise defaults to 'repo' for backward compatibility.
// 'org' scope requires `suborgproperties` to build the repository_property
// filter from - without it, falls back to 'repo' so rulesets from suborgs
// matched via suborgteams/suborgrepos aren't silently dropped.
getEffectiveRulesetScope (subOrgData) {
const requested = (subOrgData && subOrgData.ruleset_scope) || (this.config && this.config.ruleset_scope) || 'repo'
if (requested !== 'repo' && requested !== 'org') {
this.log.warn(`Invalid ruleset_scope value '${requested}', defaulting to 'repo'`)
return 'repo'
}
if (requested === 'org' && (!subOrgData || !Array.isArray(subOrgData.suborgproperties) || subOrgData.suborgproperties.length === 0)) {
return 'repo'
}
return requested
}

async updateAll () {
// this.subOrgConfigs = this.subOrgConfigs || await this.getSubOrgConfigs(this.github, this.repo, this.log)
// this.repoConfigs = this.repoConfigs || await this.getRepoConfigs(this.github, this.repo, this.log)
await this.syncOrgLevelRulesets()
Comment thread
decyjphr marked this conversation as resolved.
return this.eachRepositoryRepos(this.github, this.log).then(res => {
this.appendToResults(res)
})
}

// Applies ALL org-level rulesets in ONE Rulesets.sync() pass: both
// org-settings.yml's own top-level `rulesets:` AND every suborg's
// ruleset_scope:'org' entries (via repository_property filters). This MUST
// be a single pass - the Rulesets plugin's find()/diff (scope 'org') sees
// every org-level ruleset currently on GitHub, so a pass aware of only a
// subset of the desired state (e.g. only org-settings.yml's list, or only
// one suborg's) would delete the other subset's rulesets as "no longer
// wanted". Called from updateAll(), which every sync path (syncAll,
// syncSubOrgs, syncSelectedRepos) routes through. Always reads every
// suborg file (not just this.subOrgConfigMap, which may be restricted to
// a single changed suborg) so the desired-state computation stays
// complete regardless of which sync path triggered it.
async syncOrgLevelRulesets () {
Comment thread
decyjphr marked this conversation as resolved.
const stripMap = this.computeStripMap()
if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) {
this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin")
this.emitDisableSkip('rulesets')
return
}

const centralizedBypassActors = this.config.centralized_ruleset_bypass_actors
const entries = [...applyCentralizedBypassActors(this.config.rulesets || [], centralizedBypassActors)]
let hasOrgRulesetConfig = Array.isArray(this.config.rulesets)

const overridePaths = await this.getSubOrgConfigMap()
if (Array.isArray(overridePaths)) {
for (const override of overridePaths) {
const data = await this.loadYaml(override.path)
if (!data) continue

const sources = { suborg: data }
this.applyStrips(stripMap, sources, null)
const strippedData = sources.suborg

if (!strippedData || this.getEffectiveRulesetScope(strippedData) !== 'org' || !Array.isArray(strippedData.rulesets)) continue
hasOrgRulesetConfig = true

const propertyIncludes = strippedData.suborgproperties.map(filter => {
const [name] = Object.keys(filter)
return { name, source: 'custom', property_values: [String(filter[name])] }
})

const scopedEntries = applyCentralizedBypassActors(strippedData.rulesets, centralizedBypassActors).map(entry => {
const cloned = this.mergeDeep.mergeDeep({}, entry)
// Suffix with the suborg file so identically-named rulesets from
// different suborgs don't collide in the org's shared ruleset namespace.
cloned.name = `${entry.name} [${override.name}]`
cloned.conditions = cloned.conditions || {}
delete cloned.conditions.repository_name
delete cloned.conditions.repository_id
cloned.conditions.repository_property = { include: propertyIncludes, exclude: [] }
return cloned
})
entries.push(...scopedEntries)
}
}

if (!hasOrgRulesetConfig) return
Comment thread
decyjphr marked this conversation as resolved.

const additiveSet = this.normalizeAdditivePlugins()
const RulesetsPlugin = Settings.PLUGINS.rulesets
const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, entries, this.log, this.errors, SCOPE.ORG)
rulesetsPlugin.additive = additiveSet.has('rulesets')
await rulesetsPlugin.sync().then(res => {
Comment thread
decyjphr marked this conversation as resolved.
if (this.nop && Array.isArray(res)) {
res.forEach(r => {
if (r) {
r.repo = `${this.repo.owner} (org)`
r.fromSubOrg = true
}
})
}
this.appendToResults(res)
})
}

async updateChangedRepoConfigs (changedRepos = []) {
if (!Array.isArray(changedRepos) || changedRepos.length === 0) return

Expand Down Expand Up @@ -2371,7 +2458,25 @@ class Settings {
suborg: this.cloneAndStripDisableMeta(subOrgOverrideConfig),
repo: this.cloneAndStripDisableMeta(repoOverrideConfig)
}

// Check if suborg has rulesets before applyStrips potentially removes it
const suborgHadRulesets = sources.suborg &&
Object.prototype.hasOwnProperty.call(sources.suborg, 'rulesets')

this.applyStrips(stripMap, sources, repoName)

// ruleset_scope: 'org' suborgs get one shared org-level ruleset (see
// syncOrgLevelRulesets), so exclude them here to avoid also
// creating a duplicate per-repo ruleset for every matched repo.
// If migrating from 'repo' to 'org' scope, trigger cleanup by injecting
// an empty rulesets array so the plugin removes old repo-scoped entries.
// This must happen AFTER applyStrips so that if the user is migrating
// from repo to org scope and emptying/removing the rulesets list in the
// same change, the empty array persists to trigger cleanup (unless the
// plugin was explicitly disabled via disable_plugins).
if (sources.suborg && this.getEffectiveRulesetScope(sources.suborg) === 'org' && suborgHadRulesets) {
sources.suborg.rulesets = []
}

const overrideConfig = this.mergeDeep.mergeDeep({}, sources.org, sources.suborg, sources.repo)

Expand Down
9 changes: 9 additions & 0 deletions schema/dereferenced/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
"description": "Schema for .github/settings.yml — org-level safe-settings configuration",
"type": "object",
"properties": {
"ruleset_scope": {
"type": "string",
"description": "Default scope for rulesets in suborg files. 'repo' (default) creates individual repository-scoped rulesets. 'org' creates organization-scoped rulesets with repository_property targeting. Can be overridden per suborg.",
"enum": [
"repo",
"org"
],
"default": "repo"
},
"repositories": {
"description": "Repository settings",
"allOf": [
Expand Down
Loading