From de8bab4af871226997265ab3cba87b61aad0a504 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 3 Oct 2025 17:03:28 -0400
Subject: [PATCH 01/17] handle multiple changes as a batch
---
index.js | 56 +++++++++++++++++++------------------------------
lib/settings.js | 38 ++++++++++++++++++++++++++++-----
2 files changed, 54 insertions(+), 40 deletions(-)
diff --git a/index.js b/index.js
index e6fd1c8d7..ebb1ff929 100644
--- a/index.js
+++ b/index.js
@@ -40,7 +40,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
- async function syncSubOrgSettings (nop, context, suborg, repo = context.repo(), ref) {
+ async function syncSettings (nop, context, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
@@ -48,7 +48,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
- return Settings.syncSubOrgs(nop, context, suborg, repo, config, ref)
+ return Settings.sync(nop, context, repo, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
@@ -65,7 +65,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
- async function syncSettings (nop, context, repo = context.repo(), ref) {
+ async function syncSelectedSettings (nop, context, repos, subOrgs, ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
@@ -73,7 +73,11 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
- return Settings.sync(nop, context, repo, config, ref)
+ if (ref) {
+ return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref)
+ } else {
+ return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config)
+ }
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
@@ -81,9 +85,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
- const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
+ const nopcommand = new NopCommand(filename, context.repo(), null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
- Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
+ Settings.handleError(nop, context, context.repo(), deploymentConfig, ref, nopcommand)
} else {
throw e
}
@@ -264,17 +268,11 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
const repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
- if (repoChanges.length > 0) {
- return Promise.all(repoChanges.map(repo => {
- return syncSettings(false, context, repo)
- }))
- }
- const changes = getAllChangedSubOrgConfigs(payload)
- if (changes.length) {
- return Promise.all(changes.map(suborg => {
- return syncSubOrgSettings(false, context, suborg)
- }))
+ const subOrgChanges = getAllChangedSubOrgConfigs(payload)
+
+ if (repoChanges.length > 0 || subOrgChanges.length > 0) {
+ return syncSelectedSettings(false, context, repoChanges, subOrgChanges)
}
robot.log.debug(`No changes in '${Settings.FILE_PATH}' detected, returning...`)
@@ -572,15 +570,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
robot.log.debug(`Updating check run ${JSON.stringify(params)}`)
await context.octokit.checks.update(params)
- // guarding against null value from upstream libary that is
- // causing a 404 and the check to stall
- // from issue: https://github.com/github/safe-settings/issues/185#issuecomment-1075240374
- if (check_suite.before === '0000000000000000000000000000000000000000') {
- check_suite.before = check_suite.pull_requests[0].base.sha
- }
- params = Object.assign(context.repo(), { basehead: `${check_suite.before}...${check_suite.after}` })
- const changes = await context.octokit.repos.compareCommitsWithBasehead(params)
- const files = changes.data.files.map(f => { return f.filename })
+ params = Object.assign(context.repo(), { pull_number: pull_request.number })
+
+ const changes = await context.octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/files', params)
+ const files = changes.data.map(f => { return f.filename })
const settingsModified = files.includes(Settings.FILE_PATH)
@@ -590,17 +583,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
const repoChanges = getChangedRepoConfigName(files, context.repo().owner)
- if (repoChanges.length > 0) {
- return Promise.all(repoChanges.map(repo => {
- return syncSettings(true, context, repo, pull_request.head.ref)
- }))
- }
-
const subOrgChanges = getChangedSubOrgConfigName(files)
- if (subOrgChanges.length) {
- return Promise.all(subOrgChanges.map(suborg => {
- return syncSubOrgSettings(true, context, suborg, context.repo(), pull_request.head.ref)
- }))
+
+ if (repoChanges.length > 0 || subOrgChanges.length > 0) {
+ return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref)
}
// if no safe-settings changes detected, send a success to the check run
diff --git a/lib/settings.js b/lib/settings.js
index 6c42e439b..8d9e07b2b 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -42,6 +42,31 @@ class Settings {
}
}
+ static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref) {
+ const settings = new Settings(nop, context, context.repo(), config, ref)
+
+ try {
+ for (const repo of repos) {
+ settings.repo = repo
+ await settings.loadConfigs(repo)
+ if (settings.isRestricted(repo.repo)) {
+ continue
+ }
+ await settings.updateRepos(repo)
+ }
+ for (const suborg of subOrgs) {
+ settings.subOrgConfigMap = [suborg]
+ settings.suborgChange = !!suborg
+ await settings.loadConfigs()
+ await settings.updateAll()
+ }
+ await settings.handleResults()
+ } catch (error) {
+ settings.logError(error.message)
+ await settings.handleResults()
+ }
+ }
+
static async sync (nop, context, repo, config, ref) {
const settings = new Settings(nop, context, repo, config, ref)
try {
@@ -506,17 +531,20 @@ ${this.results.reduce((x, y) => {
log.debug('Fetching repositories')
return github.paginate('GET /installation/repositories').then(repositories => {
return Promise.all(repositories.map(repository => {
- if (this.isRestricted(repository.name)) {
- return null
- }
-
const { owner, name } = repository
- return this.updateRepos({ owner: owner.login, repo: name })
+ return this.checkAndProcessRepo(owner.login, name)
})
)
})
}
+ async checkAndProcessRepo (owner, name) {
+ if (this.isRestricted(name)) {
+ return null
+ }
+ return this.updateRepos({ owner, repo: name })
+ }
+
/**
* Loads a file from GitHub
*
From fa00d78c38df52ba66203e254fac623b0b2a2b46 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 3 Oct 2025 17:07:36 -0400
Subject: [PATCH 02/17] Update index.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
index.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/index.js b/index.js
index ebb1ff929..608a2b8a8 100644
--- a/index.js
+++ b/index.js
@@ -572,7 +572,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
params = Object.assign(context.repo(), { pull_number: pull_request.number })
- const changes = await context.octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/files', params)
+ const changes = await context.octokit.pulls.listFiles(params)
const files = changes.data.map(f => { return f.filename })
const settingsModified = files.includes(Settings.FILE_PATH)
From b87397cf4579da0ff4da07cb3bf24b2be535b10a Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 3 Oct 2025 17:08:05 -0400
Subject: [PATCH 03/17] Update index.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
index.js | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/index.js b/index.js
index 608a2b8a8..57312b710 100644
--- a/index.js
+++ b/index.js
@@ -73,11 +73,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
- if (ref) {
- return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref)
- } else {
- return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config)
- }
+ return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
From 6b358e5b81ecd35f103490ee44cba660a73f2f4f Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Sat, 4 Oct 2025 20:45:54 -0400
Subject: [PATCH 04/17] depup files in a push
---
index.js | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/index.js b/index.js
index 57312b710..3448df382 100644
--- a/index.js
+++ b/index.js
@@ -263,9 +263,19 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return syncAllSettings(false, context)
}
- const repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
+ let repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
- const subOrgChanges = getAllChangedSubOrgConfigs(payload)
+ let subOrgChanges = getAllChangedSubOrgConfigs(payload)
+ const dedupedRepos = [...new Set(repoChanges.map(r => r.repo))].map(name => {
+ return repoChanges.find(r => r.repo === name)
+ })
+ repoChanges = dedupedRepos
+ const dedupedSubOrgs = [...new Set(subOrgChanges.map(s => s.name))].map(name => {
+ return subOrgChanges.find(s => s.name === name)
+ })
+ subOrgChanges = dedupedSubOrgs
+ robot.log.debug(`deduped repos ${JSON.stringify(repoChanges)}`)
+ robot.log.debug(`deduped subOrgs ${JSON.stringify(subOrgChanges)}`)
if (repoChanges.length > 0 || subOrgChanges.length > 0) {
return syncSelectedSettings(false, context, repoChanges, subOrgChanges)
From c971041333ac4ea6e226869bea1f10017c565220 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Sat, 4 Oct 2025 20:49:28 -0400
Subject: [PATCH 05/17] Update index.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
index.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/index.js b/index.js
index 3448df382..3c54e8154 100644
--- a/index.js
+++ b/index.js
@@ -270,8 +270,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return repoChanges.find(r => r.repo === name)
})
repoChanges = dedupedRepos
- const dedupedSubOrgs = [...new Set(subOrgChanges.map(s => s.name))].map(name => {
- return subOrgChanges.find(s => s.name === name)
+ const dedupedSubOrgs = [...new Set(subOrgChanges.map(s => s.repo))].map(repo => {
+ return subOrgChanges.find(s => s.repo === repo)
})
subOrgChanges = dedupedSubOrgs
robot.log.debug(`deduped repos ${JSON.stringify(repoChanges)}`)
From ac8e195dd999e40bde5933ca1de6480001fb07ce Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Sun, 5 Oct 2025 09:12:26 -0400
Subject: [PATCH 06/17] moved the dedup logic
---
index.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/index.js b/index.js
index 3c54e8154..c757da2b1 100644
--- a/index.js
+++ b/index.js
@@ -266,8 +266,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
let repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
let subOrgChanges = getAllChangedSubOrgConfigs(payload)
- const dedupedRepos = [...new Set(repoChanges.map(r => r.repo))].map(name => {
- return repoChanges.find(r => r.repo === name)
+ const dedupedRepos = [...new Set(repoChanges.map(r => r.repo))].map(repo => {
+ return repoChanges.find(r => r.repo === repo)
})
repoChanges = dedupedRepos
const dedupedSubOrgs = [...new Set(subOrgChanges.map(s => s.repo))].map(repo => {
From 8bc76fcf92e9cd1885105ce32773b2779bd8f75a Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Sun, 5 Oct 2025 09:14:47 -0400
Subject: [PATCH 07/17] Update index.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
index.js | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/index.js b/index.js
index c757da2b1..ca2f6dc61 100644
--- a/index.js
+++ b/index.js
@@ -266,14 +266,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
let repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner)
let subOrgChanges = getAllChangedSubOrgConfigs(payload)
- const dedupedRepos = [...new Set(repoChanges.map(r => r.repo))].map(repo => {
- return repoChanges.find(r => r.repo === repo)
- })
- repoChanges = dedupedRepos
- const dedupedSubOrgs = [...new Set(subOrgChanges.map(s => s.repo))].map(repo => {
- return subOrgChanges.find(s => s.repo === repo)
- })
- subOrgChanges = dedupedSubOrgs
+ repoChanges = repoChanges.filter((r, i, arr) => arr.findIndex(item => item.repo === r.repo) === i)
+
+ subOrgChanges = subOrgChanges.filter((s, i, arr) => arr.findIndex(item => item.repo === s.repo) === i)
robot.log.debug(`deduped repos ${JSON.stringify(repoChanges)}`)
robot.log.debug(`deduped subOrgs ${JSON.stringify(subOrgChanges)}`)
From b6887a2a1704512969a6b34e626d963698ab3c17 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 15 May 2026 10:53:37 -0400
Subject: [PATCH 08/17] Start at 2.1.18-rc1 and add roles plugin and enhance
settings integration
---
lib/plugins/custom_properties.js | 50 ++++++++---
lib/plugins/custom_repository_roles.js | 119 +++++++++++++++++++++++++
lib/settings.js | 12 ++-
3 files changed, 169 insertions(+), 12 deletions(-)
create mode 100644 lib/plugins/custom_repository_roles.js
diff --git a/lib/plugins/custom_properties.js b/lib/plugins/custom_properties.js
index 6b1f3ab36..35f0144da 100644
--- a/lib/plugins/custom_properties.js
+++ b/lib/plugins/custom_properties.js
@@ -12,10 +12,24 @@ module.exports = class CustomProperties extends Diffable {
// Force all names to lowercase to avoid comparison issues.
normalizeEntries () {
- this.entries = this.entries.map(({ name, value }) => ({
- name: name.toLowerCase(),
- value
- }))
+ this.entries = this.entries.reduce((normalizedEntries, entry) => {
+ if (!entry || typeof entry !== 'object') {
+ return normalizedEntries
+ }
+
+ const entryName = entry.name || entry.property_name
+
+ if (typeof entryName !== 'string') {
+ return normalizedEntries
+ }
+
+ normalizedEntries.push({
+ name: entryName.toLowerCase(),
+ value: entry.value
+ })
+
+ return normalizedEntries
+ }, [])
}
async find () {
@@ -25,7 +39,7 @@ module.exports = class CustomProperties extends Diffable {
this.log.debug(`Getting all custom properties for the repo ${repoFullName}`)
const customProperties = await this.github.paginate(
- this.github.repos.getCustomPropertiesValues,
+ this.github.rest.repos.getCustomPropertiesValues,
{
owner,
repo,
@@ -38,10 +52,24 @@ module.exports = class CustomProperties extends Diffable {
// Force all names to lowercase to avoid comparison issues.
normalize (properties) {
- return properties.map(({ property_name: propertyName, value }) => ({
- name: propertyName.toLowerCase(),
- value
- }))
+ return properties.reduce((normalizedProperties, property) => {
+ if (!property || typeof property !== 'object') {
+ return normalizedProperties
+ }
+
+ const propertyName = property.property_name || property.name
+
+ if (typeof propertyName !== 'string') {
+ return normalizedProperties
+ }
+
+ normalizedProperties.push({
+ name: propertyName.toLowerCase(),
+ value: property.value
+ })
+
+ return normalizedProperties
+ }, [])
}
comparator (existing, attrs) {
@@ -82,14 +110,14 @@ module.exports = class CustomProperties extends Diffable {
return new NopCommand(
this.constructor.name,
this.repo,
- this.github.repos.createOrUpdateCustomPropertiesValues.endpoint(params),
+ this.github.rest.repos.createOrUpdateCustomPropertiesValues.endpoint(params),
`${operation} Custom Property`
)
}
try {
this.log.debug(`${operation} Custom Property "${name}" for the repo ${repoFullName}`)
- await this.github.repos.createOrUpdateCustomPropertiesValues(params)
+ await this.github.rest.repos.createOrUpdateCustomPropertiesValues(params)
this.log.debug(`Successfully ${operation.toLowerCase()}d Custom Property "${name}" for the repo ${repoFullName}`)
} catch (e) {
this.logError(`Error during ${operation} Custom Property "${name}" for the repo ${repoFullName}: ${e.message || e}`)
diff --git a/lib/plugins/custom_repository_roles.js b/lib/plugins/custom_repository_roles.js
new file mode 100644
index 000000000..1931b47cc
--- /dev/null
+++ b/lib/plugins/custom_repository_roles.js
@@ -0,0 +1,119 @@
+const Diffable = require('./diffable')
+const NopCommand = require('../nopcommand')
+const MergeDeep = require('../mergeDeep')
+
+// Fields returned by the API that we should ignore when diffing
+const ignorableFields = ['id', 'organization', 'created_at', 'updated_at']
+
+const version = {
+ 'X-GitHub-Api-Version': '2026-03-10'
+}
+
+module.exports = class CustomRepositoryRoles extends Diffable {
+ constructor (nop, github, repo, entries, log, errors) {
+ super(nop, github, repo, entries, log, errors)
+ this.github = github
+ this.repo = repo
+ this.entries = entries
+ this.log = log
+ this.nop = nop
+ }
+
+ // Find all Custom Repository Roles for the org
+ find () {
+ this.log.debug(`Getting all custom repository roles for the org ${this.repo.owner}`)
+
+ return this.github.request('GET /orgs/{org}/custom-repository-roles', {
+ org: this.repo.owner,
+ headers: version
+ }).then(res => {
+ const roles = (res && res.data && res.data.custom_roles) || []
+ // Strip noise so deep-diff focuses on the configurable fields
+ return roles.map(r => ({
+ id: r.id,
+ name: r.name,
+ description: r.description,
+ base_role: r.base_role,
+ permissions: r.permissions
+ }))
+ }).catch(e => {
+ return this.handleError(e, [])
+ })
+ }
+
+ comparator (existing, attrs) {
+ return existing.name === attrs.name
+ }
+
+ changed (existing, attrs) {
+ const mergeDeep = new MergeDeep(this.log, this.github, ignorableFields)
+ const merged = mergeDeep.compareDeep(existing, attrs)
+ return merged.hasChanges
+ }
+
+ update (existing, attrs) {
+ const parms = this.wrapAttrs(Object.assign({ role_id: existing.id }, attrs))
+ if (this.nop) {
+ return Promise.resolve([
+ new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('PATCH /orgs/{org}/custom-repository-roles/{role_id}', parms), 'Update Custom Repository Role')
+ ])
+ }
+ this.log.debug(`Updating Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`)
+ return this.github.request('PATCH /orgs/{org}/custom-repository-roles/{role_id}', parms).then(res => {
+ this.log.debug(`Custom Repository Role updated successfully ${JSON.stringify(res.url)}`)
+ return res
+ }).catch(e => {
+ return this.handleError(e)
+ })
+ }
+
+ add (attrs) {
+ const parms = this.wrapAttrs(attrs)
+ if (this.nop) {
+ return Promise.resolve([
+ new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('POST /orgs/{org}/custom-repository-roles', parms), 'Create Custom Repository Role')
+ ])
+ }
+ this.log.debug(`Creating Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`)
+ return this.github.request('POST /orgs/{org}/custom-repository-roles', parms).then(res => {
+ this.log.debug(`Custom Repository Role created successfully ${JSON.stringify(res.url)}`)
+ return res
+ }).catch(e => {
+ return this.handleError(e)
+ })
+ }
+
+ remove (existing) {
+ const parms = this.wrapAttrs({ role_id: existing.id })
+ if (this.nop) {
+ return Promise.resolve([
+ new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('DELETE /orgs/{org}/custom-repository-roles/{role_id}', parms), 'Delete Custom Repository Role')
+ ])
+ }
+ this.log.debug(`Deleting Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`)
+ return this.github.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', parms).then(res => {
+ this.log.debug(`Custom Repository Role deleted successfully ${JSON.stringify(res.url)}`)
+ return res
+ }).catch(e => {
+ if (e.status === 404) {
+ return
+ }
+ return this.handleError(e)
+ })
+ }
+
+ wrapAttrs (attrs) {
+ return Object.assign({}, attrs, {
+ org: this.repo.owner,
+ headers: version
+ })
+ }
+
+ handleError (e, returnValue) {
+ this.logError(e)
+ if (this.nop) {
+ return Promise.resolve([(new NopCommand(this.constructor.name, this.repo, null, `error: ${e}`, 'ERROR'))])
+ }
+ return Promise.resolve(returnValue)
+ }
+}
diff --git a/lib/settings.js b/lib/settings.js
index 8d9e07b2b..4815e5a6b 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -319,7 +319,15 @@ ${this.results.reduce((x, y) => {
const rulesetsConfig = this.config.rulesets
if (rulesetsConfig) {
const RulesetsPlugin = Settings.PLUGINS.rulesets
- return new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => {
+ await new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => {
+ this.appendToResults(res)
+ })
+ }
+
+ const customRepositoryRolesConfig = this.config.custom_repository_roles
+ if (customRepositoryRolesConfig) {
+ const CustomRepositoryRolesPlugin = Settings.PLUGINS.custom_repository_roles
+ await new CustomRepositoryRolesPlugin(this.nop, this.github, this.repo, customRepositoryRolesConfig, this.log, this.errors).sync().then(res => {
this.appendToResults(res)
})
}
@@ -434,6 +442,7 @@ ${this.results.reduce((x, y) => {
returnRepoSpecificConfigs (config) {
const newConfig = Object.assign({}, config) // clone
delete newConfig.rulesets
+ delete newConfig.custom_repository_roles
return newConfig
}
@@ -1004,6 +1013,7 @@ Settings.PLUGINS = {
rulesets: require('./plugins/rulesets'),
environments: require('./plugins/environments'),
custom_properties: require('./plugins/custom_properties.js'),
+ custom_repository_roles: require('./plugins/custom_repository_roles'),
variables: require('./plugins/variables')
}
From bdcc6b57eae13dc3474b24ed47ffd0fcb329f988 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Fri, 15 May 2026 10:54:07 -0400
Subject: [PATCH 09/17] Add custom repository roles schema to settings.json
---
schema/settings.json | 44 +++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 43 insertions(+), 1 deletion(-)
diff --git a/schema/settings.json b/schema/settings.json
index 4d390b38f..3649d88ea 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -191,6 +191,48 @@
"items": {
"$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1rulesets/post/requestBody/content/application~1json/schema"
}
+ },
+ "custom_repository_roles": {
+ "description": "Org-level custom repository roles. Only valid in the org-level settings.yml.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "base_role",
+ "permissions"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the custom role."
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "A short description of the role."
+ },
+ "base_role": {
+ "type": "string",
+ "enum": [
+ "read",
+ "triage",
+ "write",
+ "maintain"
+ ],
+ "description": "The system role from which this role inherits permissions."
+ },
+ "permissions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Additional fine-grained permissions included in this role."
+ }
+ }
+ }
}
}
-}
+}
\ No newline at end of file
From 1d739f9f8cfa8bb4d72c8a53d779e52cf169cc58 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 19 May 2026 01:59:10 -0400
Subject: [PATCH 10/17] Add sub-org reevaluation logic and smoke tests
---
README.md | 87 +++
lib/plugins/diffable.js | 6 +
lib/plugins/repository.js | 12 +
lib/settings.js | 176 +++++-
package.json | 3 +-
smoke-test.js | 945 +++++++++++++++++++++++++++++++++
test/unit/lib/settings.test.js | 181 +++++++
7 files changed, 1405 insertions(+), 5 deletions(-)
create mode 100644 smoke-test.js
diff --git a/README.md b/README.md
index 07a626748..b7382de34 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,27 @@ The App listens to the following webhook events:
- __custom_property_values__: If new repository properties are set for a repository, `safe-settings` will run to so that if a sub-org config is defined by that property, it will be applied for the repo
+### Suborg re-evaluation after repo-level changes
+
+A repo's suborg membership can depend on state that is itself written by `safe-settings`:
+
+- `suborgteams` — repos belong to a suborg because a given team is granted access
+- `suborgproperties` — repos belong to a suborg because a custom property has a given value
+- `suborgrepos` — repos belong to a suborg because their name matches a glob
+
+When a repo-level change (a push to `.github/repos/.yml`, or a `repository.created` event for a brand-new repo) adds a team, sets a custom property, or creates a repo whose name matches a suborg's `suborgrepos` glob, the repo may *newly* match a suborg config that was not applied in the first pass.
+
+To handle this, after applying a repo-yml change `safe-settings` re-evaluates the repo's suborg membership and, if a new suborg now matches, runs the repo through the apply pipeline a second time so the suborg's settings are picked up in the same sync.
+
+**Scope:** Re-evaluation runs only on the repo-yml change paths (`Settings.sync` and the per-repo loop of `Settings.syncSelectedRepos`). Global settings changes (`syncAll`) and suborg-yml changes (`syncSubOrgs`) already iterate all relevant repos and do not need it.
+
+**Loop prevention.** Two guards prevent infinite re-evaluation:
+
+1. **Stability check (primary):** Before applying changes, `safe-settings` snapshots the set of suborg source paths that match the repo. After applying, it refreshes the suborg cache and recomputes the set. If no new suborg source appeared, re-evaluation stops.
+2. **Hard depth cap (safety net):** Each repo is re-evaluated at most `MAX_REEVALUATION_DEPTH = 1` time per sync. This resolves the dominant single-hop case (repo change → newly-matched suborg → apply suborg once) while preventing pathological chains (suborg A applies a team that activates suborg B that activates suborg C…). Chains beyond one hop are resolved on the next sync event, and a warning is logged when the cap is hit.
+
+**Trigger optimization.** Re-evaluation is skipped entirely when the resolved `repoConfig` has no `teams`, no `custom_properties`, and is not a rename — these are the only repo-level changes that can affect suborg matching.
+
### Use `safe-settings` to rename repos
If you rename a `` that corresponds to a repo, safe-settings will rename the repo to the new name. This behavior will take effect whether the env variable `BLOCK_REPO_RENAME_BY_HUMAN` is set or not.
@@ -573,7 +594,73 @@ You can pass environment variables; the easiest way to do it is via a `.env` fil
3. __[Deploy and install the app](docs/deploy.md)__. Alternatively, the __[GitHub Actions Guide](docs/github-action.md)__ describes how to run `safe-settings` with GitHub Actions.
+## Smoke Testing
+
+The repository includes an end-to-end smoke test script (`smoke-test.js`) that validates safe-settings against a live GitHub organization. It starts the app, creates repos/configs via the API, and verifies that safe-settings correctly applies and enforces settings.
+
+### Prerequisites
+
+- **Node.js** (same version used to run safe-settings)
+- **`gh` CLI** — authenticated and available on PATH (used for drift-remediation tests only)
+- A **GitHub App** installed on the target org with the required permissions
+- A `.env` file in the project root (see below)
+
+### Authentication
+
+The smoke test uses **two authentication methods**:
+
+- **GitHub App token** (via `APP_ID` + `PRIVATE_KEY`) — used for the majority of tests: creating configs, merging PRs, validating repos, teams, rulesets, custom properties, etc.
+- **Fine-grained PAT** (via `GH_TOKEN`) — used **only** in Phase 2 (team removal) and Phase 3 (rogue ruleset creation). These drift-remediation tests must appear as a human action because safe-settings ignores webhook events where `sender.type` is `Bot`.
+### Configuration
+
+Add the following to your `.env` file:
+
+| Variable | Description | Required |
+|---|---|---|
+| `GH_ORG` | Target GitHub organization (e.g. `my-org`) | Yes |
+| `APP_ID` | GitHub App ID | Yes |
+| `PRIVATE_KEY` | GitHub App private key (use `\n` for newlines) | Yes |
+| `WEBHOOK_PROXY_URL` | Smee.io proxy URL for webhooks | Yes |
+| `ADMIN_REPO` | Admin repo name (default: `admin`) | No |
+| `CONFIG_PATH` | Config path within admin repo (default: `.github`) | No |
+| `GH_TOKEN` | Fine-grained PAT with org admin + repo permissions | Yes |
+| `SMOKE_VERBOSE` | Set to `1` to show live safe-settings logs | No |
+
+### Running
+
+```bash
+npm run smoke-test
+# or
+node smoke-test.js
+```
+
+### What it tests
+
+The smoke test runs 9 phases:
+
+| Phase | Description |
+|---|---|
+| **Setup** | Initializes the admin repo with an empty `settings.yml`, removes stale test repos, and starts safe-settings |
+| **Phase 1** | Creates a repo config (`test`), validates NOP mode via check runs, merges, and verifies repo creation, teams, custom properties, and rulesets |
+| **Phase 2** | Removes a team from the repo and verifies safe-settings re-adds it (drift remediation) |
+| **Phase 3** | Creates a rogue ruleset and verifies safe-settings removes it (drift remediation) |
+| **Phase 4** | Creates `demo-repo-service1` with teams, topics, and branch protection |
+| **Phase 5** | Creates a suborg config and verifies org-scoped rulesets are applied to matching repos |
+| **Phase 6** | Archives `demo-repo-service1` and verifies the repo is archived |
+| **Phase 7** | Creates `demo-repo-service2` and verifies suborg rulesets are inherited |
+| **Phase 8** | Creates org-level settings (custom repository roles + org rulesets) and verifies they are applied |
+| **Teardown** | Shuts down safe-settings, deletes test repos, teams, custom roles, and rulesets |
+
+### Output
+
+The script uses colored terminal output with pass (✅) / fail (❌) indicators and prints a summary at the end:
+
+```
+══════════════════════════════════════
+ Results: 45 passed, 0 failed
+══════════════════════════════════════
+```
## License
diff --git a/lib/plugins/diffable.js b/lib/plugins/diffable.js
index 069c68c78..44e5ae50c 100644
--- a/lib/plugins/diffable.js
+++ b/lib/plugins/diffable.js
@@ -62,6 +62,11 @@ module.exports = class Diffable extends ErrorStash {
sync () {
const resArray = []
+ // Will be set to true when this plugin makes (or would make, in nop mode)
+ // any add/update/remove. Consumers (e.g. Settings suborg re-evaluation)
+ // can read `plugin.hasChanges` after `sync()` resolves to know whether
+ // anything actually changed for this repo.
+ this.hasChanges = false
if (this.entries) {
let filteredEntries = this.filterEntries()
// this.log.debug(`filtered entries are ${JSON.stringify(filteredEntries)}`)
@@ -72,6 +77,7 @@ module.exports = class Diffable extends ErrorStash {
const compare = mergeDeep.compareDeep(existingRecords, filteredEntries)
const results = { msg: 'Changes found', additions: compare.additions, modifications: compare.modifications, deletions: compare.deletions }
this.log.debug(`Results of comparing ${this.constructor.name} diffable target ${JSON.stringify(existingRecords)} with source ${JSON.stringify(filteredEntries)} is ${JSON.stringify(results)}`)
+ this.hasChanges = !!compare.hasChanges
if (!compare.hasChanges) {
this.log.debug(`There are no changes for ${this.constructor.name} for repo ${this.repo.repo}. Skipping changes`)
return Promise.resolve()
diff --git a/lib/plugins/repository.js b/lib/plugins/repository.js
index 14599f608..3333225d2 100644
--- a/lib/plugins/repository.js
+++ b/lib/plugins/repository.js
@@ -62,6 +62,10 @@ module.exports = class Repository extends ErrorStash {
const resArray = []
this.log.debug(`Syncing Repo ${this.settings.name}`)
this.settings.name = this.settings.name || this.settings.repo
+ // Change signals consumed by Settings suborg re-evaluation.
+ this.hasChanges = false
+ this.renamed = false
+ this.created = false
// let hasChanges = false
// let hasTopicChanges = false
return this.github.repos.get(this.repo)
@@ -74,6 +78,12 @@ module.exports = class Repository extends ErrorStash {
const topicChanges = mergeDeep.compareDeep({ entries: resp.data.topics }, { entries: this.topics })
// hasTopicChanges = topicChanges.additions.length > 0 || topicChanges.modifications.length > 0
+ this.hasChanges = !!(changes.hasChanges || topicChanges.hasChanges)
+ // A repo rename (changing the slug) shows up as a `name` modification.
+ if (changes.hasChanges && this.settings.name && resp.data.name && this.settings.name !== resp.data.name) {
+ this.renamed = true
+ }
+
// const results = JSON.stringify(changes, null, 2)
const results = { msg: `${this.constructor.name} settings changes`, additions: changes.additions, modifications: changes.modifications, deletions: changes.deletions }
@@ -120,6 +130,8 @@ module.exports = class Repository extends ErrorStash {
}).catch(e => {
if (e.status === 404) {
if (this.force_create) {
+ this.hasChanges = true
+ this.created = true
if (this.template) {
this.log.debug(`Creating repo using template ${this.template}`)
const options = { template_owner: this.repo.owner, template_repo: this.template, owner: this.repo.owner, name: this.repo.repo, private: (this.settings.private ? this.settings.private : true), description: this.settings.description ? this.settings.description : '' }
diff --git a/lib/settings.js b/lib/settings.js
index 4815e5a6b..d104efa5b 100644
--- a/lib/settings.js
+++ b/lib/settings.js
@@ -12,6 +12,12 @@ const eta = new Eta({ views: path.join(__dirname) })
const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting
const yaml = require('js-yaml')
+// When a repo-yml change applies teams/properties/etc to a repo, the repo may
+// newly match a suborg config (via suborgteams/suborgproperties/suborgrepos).
+// Re-run updateRepos for the same repo at most this many times. Depth=1 is the
+// tightest cap: we resolve a single hop of newly-matched suborg per sync.
+const MAX_REEVALUATION_DEPTH = 1
+
class Settings {
static fileCache = {}
@@ -46,6 +52,10 @@ class Settings {
const settings = new Settings(nop, context, context.repo(), config, ref)
try {
+ // Re-eval is enabled only for the per-repo iteration (repo-yml change
+ // path). The trailing suborg iteration below already iterates all suborg
+ // repos, so it is left with the flag off.
+ settings.reevaluateOnChange = true
for (const repo of repos) {
settings.repo = repo
await settings.loadConfigs(repo)
@@ -54,6 +64,7 @@ class Settings {
}
await settings.updateRepos(repo)
}
+ settings.reevaluateOnChange = false
for (const suborg of subOrgs) {
settings.subOrgConfigMap = [suborg]
settings.suborgChange = !!suborg
@@ -70,6 +81,10 @@ class Settings {
static async sync (nop, context, repo, config, ref) {
const settings = new Settings(nop, context, repo, config, ref)
try {
+ // Repo-yml change path: re-evaluate suborg membership for this repo if
+ // the applied changes (teams/custom_properties/new repo) cause it to
+ // newly match a suborg config.
+ settings.reevaluateOnChange = true
await settings.loadConfigs(repo)
if (settings.isRestricted(repo.repo)) {
return
@@ -124,6 +139,13 @@ class Settings {
}
}
this.mergeDeep = new MergeDeep(this.log, this.github, [], this.configvalidators, this.overridevalidators)
+ // Suborg re-evaluation state (used only when reevaluateOnChange is true).
+ // - reevaluationDepth: repo name -> number of re-evaluation passes done.
+ // - reevaluatedRepos: repo name -> set of suborg source paths seen so far
+ // (used for stability comparison; if no new sources appear, we stop).
+ this.reevaluateOnChange = false
+ this.reevaluationDepth = new Map()
+ this.reevaluatedRepos = new Map()
}
// Create a check in the Admin repo for safe-settings.
@@ -335,6 +357,12 @@ ${this.results.reduce((x, y) => {
async updateRepos (repo) {
this.subOrgConfigs = this.subOrgConfigs || await this.getSubOrgConfigs()
+ // Snapshot the set of suborg `source` paths that match this repo *before*
+ // we apply any changes. We compare against the post-apply set below to
+ // decide whether to re-evaluate (and to break stable loops).
+ const preMatchedSuborgSources = this.reevaluateOnChange
+ ? this.getAllMatchingSubOrgSources(repo.repo)
+ : null
// Keeping this as is instead of doing an object assign as that would cause `Cannot read properties of undefined (reading 'startsWith')` error
// Copilot code review would recoommend using object assign but that would cause the error
let repoConfig = this.config.repository
@@ -367,6 +395,10 @@ ${this.results.reduce((x, y) => {
repoConfig = this.mergeDeep.mergeDeep({}, repoConfig, overrideRepoConfig)
}
if (repoConfig) {
+ // Track actual change signals from the plugins, used by the suborg
+ // re-evaluation logic below to avoid an unnecessary live API round-trip
+ // when nothing relevant actually changed.
+ const changeSignals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false }
try {
this.log.debug(`found a matching repoconfig for this repo ${JSON.stringify(repoConfig)}`)
@@ -382,16 +414,27 @@ ${this.results.reduce((x, y) => {
this.appendToResults(unArchiveResults)
}
- const repoResults = await new RepoPlugin(this.nop, this.github, repo, repoConfig, this.installation_id, this.log, this.errors).sync()
+ const repoPluginInstance = new RepoPlugin(this.nop, this.github, repo, repoConfig, this.installation_id, this.log, this.errors)
+ const repoResults = await repoPluginInstance.sync()
this.appendToResults(repoResults)
+ if (repoPluginInstance.renamed) changeSignals.renamed = true
+ if (repoPluginInstance.created) changeSignals.created = true
+ const childPluginInstances = childPlugins.map(([Plugin, config]) => {
+ return [Plugin, new Plugin(this.nop, this.github, repo, config, this.log, this.errors)]
+ })
const childResults = await Promise.all(
- childPlugins.map(([Plugin, config]) => {
- return new Plugin(this.nop, this.github, repo, config, this.log, this.errors).sync()
- })
+ childPluginInstances.map(([, instance]) => instance.sync())
)
this.appendToResults(childResults)
+ // Collect change signals from relevant child plugins.
+ for (const [Plugin, instance] of childPluginInstances) {
+ if (!instance.hasChanges) continue
+ if (Plugin === Settings.PLUGINS.teams) changeSignals.teamsChanged = true
+ if (Plugin === Settings.PLUGINS.custom_properties) changeSignals.propertiesChanged = true
+ }
+
if (shouldArchive) {
this.log.debug(`Archiving repo ${repo.repo}`)
const archiveResults = await archivePlugin.sync()
@@ -407,6 +450,14 @@ ${this.results.reduce((x, y) => {
throw e
}
}
+
+ // Suborg re-evaluation: if a repo-yml change actually applied teams or
+ // custom_properties (or this repo was just renamed/created), the repo
+ // may newly match a suborg config (suborgteams/suborgproperties/
+ // suborgrepos). Refresh the suborg cache, compare matched-source sets;
+ // if it grew, re-run updateRepos once for this repo. Bounded by
+ // MAX_REEVALUATION_DEPTH and a stable-set check to prevent loops.
+ await this.maybeReevaluateSuborg(repo, repoConfig, preMatchedSuborgSources, changeSignals)
} else {
this.log.debug(`Didnt find any a matching repoconfig for this repo ${JSON.stringify(repo)} in ${JSON.stringify(this.repoConfigs)}`)
const childPlugins = this.childPluginsList(repo)
@@ -438,6 +489,123 @@ ${this.results.reduce((x, y) => {
return undefined
}
+ // Read-only helper used for suborg re-evaluation stability checks.
+ // Returns the set of suborg `source` paths (i.e. the suborg config file path)
+ // that match the given repo name. Apply-time behavior is unchanged:
+ // `getSubOrgConfig` still returns the first match and
+ // `storeSubOrgConfigIfNoConflicts` still forbids multi-suborg overlap at
+ // config-load time -- so this set normally contains 0 or 1 entries. We
+ // expose it as a Set so callers can detect the transition from {} -> {pathA}
+ // when a repo newly matches a suborg after teams/properties are applied.
+ getAllMatchingSubOrgSources (repoName) {
+ const sources = new Set()
+ if (!this.subOrgConfigs) {
+ return sources
+ }
+ for (const pattern of Object.keys(this.subOrgConfigs)) {
+ const glob = new Glob(pattern)
+ if (glob.test(repoName)) {
+ const source = this.subOrgConfigs[pattern]?.source
+ if (source) {
+ sources.add(source)
+ }
+ }
+ }
+ return sources
+ }
+
+ // Force a refresh of the cached suborg configs. Used by the re-eval loop
+ // because suborgteams / suborgproperties resolution calls live GitHub APIs
+ // and may now match the repo after teams/properties were applied in the
+ // first pass.
+ async reloadSubOrgConfigs () {
+ this.subOrgConfigs = await this.getSubOrgConfigs()
+ }
+
+ // Decide whether applying this repo's config actually changed state that
+ // could affect suborg matching. If no relevant change happened, skip the
+ // re-eval API roundtrip entirely.
+ //
+ // Preferred path: use plugin-emitted change signals from the just-completed
+ // sync (teams plugin actually added/removed/updated, custom_properties
+ // plugin changed values, repository plugin renamed/created). These come
+ // from the Diffable base class (`plugin.hasChanges`) and the Repository
+ // plugin (`renamed`, `created`).
+ //
+ // Fallback (changeSignals omitted, e.g. unit tests calling the helper in
+ // isolation): inspect the per-repo yml top-level shape for teams /
+ // custom_properties / rename indicators.
+ shouldConsiderReevaluation (repo, repoConfig, changeSignals) {
+ if (changeSignals) {
+ return !!(
+ changeSignals.teamsChanged ||
+ changeSignals.propertiesChanged ||
+ changeSignals.renamed ||
+ changeSignals.created
+ )
+ }
+ const repoYml = this.repoConfigs && (
+ this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`]
+ )
+ if (repoYml) {
+ if (Array.isArray(repoYml.teams) && repoYml.teams.length > 0) return true
+ if (Array.isArray(repoYml.custom_properties) && repoYml.custom_properties.length > 0) return true
+ }
+ if (repo && repo.oldname && repo.oldname !== repo.repo) return true
+ if (repoConfig && repoConfig.oldname && repoConfig.oldname !== repoConfig.name) return true
+ return false
+ }
+
+ // After applying changes to a repo, decide whether to re-run updateRepos
+ // because the applied changes may have caused the repo to newly match a
+ // suborg config. Loop prevention has two layers:
+ // 1. Hard cap: MAX_REEVALUATION_DEPTH (=1) re-evaluation passes per repo.
+ // 2. Stability check: stop if the set of matched suborg sources did not
+ // grow (no new suborg source appeared since the last pass).
+ async maybeReevaluateSuborg (repo, repoConfig, preMatchedSuborgSources, changeSignals) {
+ if (!this.reevaluateOnChange) return
+ if (!preMatchedSuborgSources) return
+ if (!this.shouldConsiderReevaluation(repo, repoConfig, changeSignals)) {
+ this.log.debug(`Suborg re-eval: skipping for ${repo.repo} (no relevant changes from teams/custom_properties/repository plugins)`)
+ return
+ }
+
+ const depth = this.reevaluationDepth.get(repo.repo) || 0
+ if (depth >= MAX_REEVALUATION_DEPTH) {
+ this.log.warn(`Suborg re-eval: max depth (${MAX_REEVALUATION_DEPTH}) reached for ${repo.repo}; stopping. Any further suborg matches will be picked up on the next sync.`)
+ return
+ }
+
+ // Refresh suborg config cache; suborgteams/suborgproperties resolution
+ // hits live GitHub APIs and may now match this repo.
+ await this.reloadSubOrgConfigs()
+
+ const seen = this.reevaluatedRepos.get(repo.repo) || new Set(preMatchedSuborgSources)
+ const newMatched = this.getAllMatchingSubOrgSources(repo.repo)
+
+ // Stability check: if no new suborg source appeared, we're done.
+ let hasNew = false
+ for (const source of newMatched) {
+ if (!seen.has(source)) {
+ hasNew = true
+ seen.add(source)
+ }
+ }
+ if (!hasNew) {
+ this.log.debug(`Suborg re-eval: stable for ${repo.repo} (matched sources: ${JSON.stringify(Array.from(newMatched))}); stopping.`)
+ return
+ }
+
+ this.reevaluatedRepos.set(repo.repo, seen)
+ this.reevaluationDepth.set(repo.repo, depth + 1)
+ this.log.debug(`Suborg re-eval: new suborg source(s) matched ${repo.repo} after apply; re-running updateRepos (depth=${depth + 1}).`)
+
+ // Reload repo-level configs for this repo so the next pass picks up any
+ // state changes; then recurse. Depth cap above prevents infinite loops.
+ this.repoConfigs = await this.getRepoConfigs(repo)
+ await this.updateRepos(repo)
+ }
+
// Remove Org specific configs from the repo config
returnRepoSpecificConfigs (config) {
const newConfig = Object.assign({}, config) // clone
diff --git a/package.json b/package.json
index fbfa284da..f0ae365bf 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"test:me": "jest ",
"test:unit:watch": "npm run test:unit -- --watch",
"test:integration": "jest --roots=lib --roots=test/integration",
- "test:integration:debug": "LOG_LEVEL=debug DEBUG=nock run-s test:integration"
+ "test:integration:debug": "LOG_LEVEL=debug DEBUG=nock run-s test:integration",
+ "smoke-test": "node smoke-test.js"
},
"author": "Yadhav Jayaraman",
"license": "ISC",
diff --git a/smoke-test.js b/smoke-test.js
new file mode 100644
index 000000000..dcb4df8af
--- /dev/null
+++ b/smoke-test.js
@@ -0,0 +1,945 @@
+#!/usr/bin/env node
+
+/**
+ * Smoke Test for safe-settings
+ *
+ * Usage:
+ * 1. Ensure `.env` is configured with GH_ORG, APP_ID, PRIVATE_KEY, WEBHOOK_PROXY_URL, etc.
+ * 2. Set GH_TOKEN env var to a fine-grained PAT with org admin + repo permissions.
+ * This is required for drift-remediation tests (Phases 2 & 3) so that
+ * changes appear as a human (not Bot) and trigger safe-settings webhooks.
+ * 3. Run: `node smoke-test.js`
+ * Set SMOKE_VERBOSE=1 for live safe-settings logs.
+ *
+ * Auth:
+ * - Octokit (GitHub App): APP_ID + PRIVATE_KEY from .env — used for most operations.
+ * - gh CLI (user PAT): GH_TOKEN env var — used for drift tests only.
+ */
+
+const { execSync, spawn } = require('child_process')
+const fs = require('fs')
+const path = require('path')
+
+// ─── Configuration ───────────────────────────────────────────────────────────
+
+function loadEnv () {
+ const envPath = path.join(__dirname, '.env')
+ if (!fs.existsSync(envPath)) throw new Error('.env file not found')
+ const lines = fs.readFileSync(envPath, 'utf8').split('\n')
+ let currentKey = null
+ let currentValue = ''
+ let inMultiline = false
+
+ for (const line of lines) {
+ if (inMultiline) {
+ currentValue += '\n' + line
+ if (line.includes('"') || line.includes("'")) {
+ const val = currentValue.replace(/^["']|["']$/g, '')
+ // Like dotenv: .env values don't override existing env vars
+ if (!(currentKey in process.env)) process.env[currentKey] = val
+ inMultiline = false
+ }
+ continue
+ }
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const eqIdx = trimmed.indexOf('=')
+ if (eqIdx === -1) continue
+ currentKey = trimmed.slice(0, eqIdx).trim()
+ currentValue = trimmed.slice(eqIdx + 1).trim()
+ if ((currentValue.startsWith('"') && !currentValue.endsWith('"')) ||
+ (currentValue.startsWith("'") && !currentValue.endsWith("'"))) {
+ inMultiline = true
+ continue
+ }
+ const val = currentValue.replace(/^["']|["']$/g, '')
+ if (!(currentKey in process.env)) process.env[currentKey] = val
+ }
+}
+
+loadEnv()
+
+const ORG = process.env.GH_ORG || 'decyjphr-emu'
+const ADMIN_REPO = process.env.ADMIN_REPO || 'admin'
+const CONFIG_PATH = process.env.CONFIG_PATH || '.github'
+const APP_ID = process.env.APP_ID
+const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n')
+
+const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2']
+const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers']
+
+const POLL_INTERVAL_MS = 5000
+const MAX_POLL_MS = 120000
+const WEBHOOK_SETTLE_MS = 15000
+
+// Fine-grained PAT for drift tests (must appear as a human, not Bot)
+const GH_TOKEN = process.env.GH_TOKEN || ''
+
+// ─── Octokit client (initialized in main) ────────────────────────────────────
+
+let octokit = null
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+let passCount = 0
+let failCount = 0
+const failures = []
+
+function log (msg) { console.log(`\x1b[36m[smoke]\x1b[0m ${msg}`) }
+function logPass (msg) { passCount++; console.log(`\x1b[32m ✓ ${msg}\x1b[0m`) }
+function logFail (msg) { failCount++; failures.push(msg); console.log(`\x1b[31m ✗ ${msg}\x1b[0m`) }
+function logPhase (msg) { console.log(`\n\x1b[35m═══ ${msg} ═══\x1b[0m`) }
+
+function assert (condition, msg) {
+ if (condition) logPass(msg)
+ else logFail(msg)
+ return condition
+}
+
+function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
+
+async function poll (fn, { timeout = MAX_POLL_MS, interval = POLL_INTERVAL_MS, desc = 'condition' } = {}) {
+ const start = Date.now()
+ while (Date.now() - start < timeout) {
+ const result = await fn()
+ if (result) return result
+ await sleep(interval)
+ }
+ log(` ⚠ Timed out waiting for ${desc}`)
+ return null
+}
+
+// ─── GitHub API helpers ──────────────────────────────────────────────────────
+
+async function getDefaultBranch () {
+ const { data } = await octokit.rest.repos.get({ owner: ORG, repo: ADMIN_REPO })
+ return data.default_branch || 'main'
+}
+
+async function createOrUpdateFile (owner, repo, filePath, content, branch, message) {
+ const b64 = Buffer.from(content).toString('base64')
+ let sha = null
+ try {
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch })
+ sha = data.sha
+ } catch { /* file doesn't exist */ }
+ const params = { owner, repo, path: filePath, message, content: b64, branch }
+ if (sha) params.sha = sha
+ return (await octokit.rest.repos.createOrUpdateFileContents(params)).data
+}
+
+async function deleteFile (owner, repo, filePath, branch, message) {
+ try {
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch })
+ await octokit.rest.repos.deleteFile({ owner, repo, path: filePath, message, sha: data.sha, branch })
+ } catch { /* file doesn't exist */ }
+}
+
+async function cleanDirectory (owner, repo, dirPath) {
+ const branch = await getDefaultBranch()
+ try {
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dirPath, ref: branch })
+ if (Array.isArray(data)) {
+ for (const file of data) {
+ if (file.type === 'file') {
+ await deleteFile(owner, repo, file.path, branch, `Clean up ${file.path}`)
+ }
+ }
+ }
+ } catch { /* directory doesn't exist */ }
+}
+
+async function createBranch (owner, repo, branchName) {
+ const defaultBranch = await getDefaultBranch()
+ const { data: ref } = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${defaultBranch}` })
+ await octokit.rest.git.createRef({ owner, repo, ref: `refs/heads/${branchName}`, sha: ref.object.sha })
+}
+
+async function deleteBranch (owner, repo, branch) {
+ try { await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }) } catch { /* ok */ }
+}
+
+async function createPR (owner, repo, title, head, base) {
+ const { data } = await octokit.rest.pulls.create({ owner, repo, title, head, base, body: `Smoke test: ${title}` })
+ log(` Created PR #${data.number}`)
+ return data
+}
+
+async function mergePR (owner, repo, prNumber) {
+ return (await octokit.rest.pulls.merge({ owner, repo, pull_number: prNumber, merge_method: 'merge' })).data
+}
+
+async function deleteRepo (owner, repo) {
+ try { await octokit.rest.repos.delete({ owner, repo }) } catch { /* ok */ }
+}
+
+async function deleteTeam (org, teamSlug) {
+ try { await octokit.rest.teams.deleteInOrg({ org, team_slug: teamSlug }) } catch { /* ok */ }
+}
+
+async function waitForCheckRun (owner, repo, sha, { timeout = MAX_POLL_MS } = {}) {
+ return poll(async () => {
+ const { data } = await octokit.rest.checks.listForRef({ owner, repo, ref: sha })
+ const cr = data.check_runs.find(c => c.name === 'Safe-setting validator')
+ return (cr && cr.status === 'completed') ? cr : null
+ }, { timeout, desc: 'check run to complete' })
+}
+
+// ─── Safe-settings process management ────────────────────────────────────────
+
+let ssProcess = null
+
+function startSafeSettings () {
+ log('Starting safe-settings...')
+ ssProcess = spawn('npm', ['start'], {
+ cwd: __dirname,
+ env: process.env,
+ stdio: ['ignore', 'pipe', 'pipe']
+ })
+ ssProcess.stdout.on('data', (d) => { if (process.env.SMOKE_VERBOSE) process.stdout.write(d) })
+ ssProcess.stderr.on('data', (d) => { if (process.env.SMOKE_VERBOSE) process.stderr.write(d) })
+ ssProcess.on('exit', (code) => { log(`safe-settings exited with code ${code}`) })
+}
+
+function stopSafeSettings () {
+ if (ssProcess) {
+ log('Stopping safe-settings...')
+ ssProcess.kill('SIGTERM')
+ ssProcess = null
+ }
+}
+
+// ─── YAML Configs ────────────────────────────────────────────────────────────
+
+const REPO_TEST_YML = `repository:
+ name: test
+ description: Demo repository created via safe-settings
+ private: true
+ auto_init: true
+ force_create: true
+ has_issues: true
+ has_projects: false
+ has_wiki: false
+ delete_branch_on_merge: true
+ allow_squash_merge: true
+ allow_merge_commit: false
+ allow_rebase_merge: true
+
+teams:
+ - name: expert-services-developers
+ permission: push
+
+custom_properties:
+ - property_name: ent-ownership
+ value: expert-services
+ - property_name: ent-supervisory-org
+ value: expert-services
+
+rulesets:
+- name: synk
+ target: branch
+ enforcement: disabled
+ bypass_actors:
+ - actor_id: 1
+ actor_type: OrganizationAdmin
+ bypass_mode: pull_request
+
+ conditions:
+ ref_name:
+ include: ["~DEFAULT_BRANCH"]
+ exclude: ["refs/heads/oldmaster"]
+
+ rules:
+ - type: creation
+ - type: update
+ - type: deletion
+ - type: required_linear_history
+ - type: required_signatures
+ - type: pull_request
+ parameters:
+ dismiss_stale_reviews_on_push: true
+ require_code_owner_review: true
+ require_last_push_approval: true
+ required_approving_review_count: 2
+ required_review_thread_resolution: true
+
+ - type: commit_message_pattern
+ parameters:
+ name: test commit_message_pattern
+ negate: true
+ operator: starts_with
+ pattern: skip*
+
+ - type: commit_author_email_pattern
+ parameters:
+ name: test commit_author_email_pattern
+ negate: false
+ operator: regex
+ pattern: "^.*@example.com$"
+
+ - type: committer_email_pattern
+ parameters:
+ name: test committer_email_pattern
+ negate: false
+ operator: regex
+ pattern: "^.*@example.com$"
+
+ - type: branch_name_pattern
+ parameters:
+ name: test branch_name_pattern
+ negate: false
+ operator: regex
+ pattern: ".*\\\\/.*"
+
+- name: Prevent merges when new SONAR alerts are introduced
+ target: branch
+ enforcement: active
+ conditions:
+ ref_name:
+ include:
+ - "~DEFAULT_BRANCH"
+ exclude: []
+ bypass_actors:
+ - actor_type: OrganizationAdmin
+ bypass_mode: always
+ rules:
+ - type: code_scanning
+ parameters:
+ code_scanning_tools:
+ - tool: Sonar
+ alerts_threshold: none
+ security_alerts_threshold: medium_or_higher
+`
+
+const REPO_DEMO_SERVICE1_YML = `# Safe-Settings Configuration
+repository:
+ name: demo-repo-service1
+ description: "Repository 2 sample"
+ visibility: private
+ default_branch: main
+ homepage: ""
+ auto_init: true
+ force_create: true
+ delete_branch_on_merge: true
+ archived: false
+ topics:
+ - topic1
+ - topic2
+
+teams:
+ - name: AD-GRP-PAYMENTS-PLATFORM-OWNERS
+ permission: admin
+ - name: awesometeam-a-approvers
+ permission: push
+ - name: expert-services-developers
+ permission: push
+
+branches:
+ - name: main
+ protection:
+ required_status_checks:
+ strict: true
+ contexts: []
+ required_pull_request_reviews:
+ required_approving_review_count: 2
+ dismiss_stale_reviews: false
+ require_code_owner_reviews: true
+ require_last_push_approval: false
+ bypass_pull_request_allowances:
+ apps: []
+ users: []
+ teams: []
+ dismissal_restrictions:
+ users: []
+ teams: []
+ enforce_admins: true
+ restrictions:
+ apps: []
+ users: []
+ teams: []
+
+ - name: develop
+ protection:
+ required_status_checks:
+ strict: true
+ contexts: []
+ required_pull_request_reviews:
+ required_approving_review_count: 1
+ dismiss_stale_reviews: false
+ require_code_owner_reviews: true
+ require_last_push_approval: false
+ bypass_pull_request_allowances:
+ apps: []
+ users: []
+ teams: []
+ dismissal_restrictions:
+ users: []
+ teams: []
+ enforce_admins: true
+ restrictions:
+ apps: []
+ users: []
+ teams: []
+`
+
+const SUBORG_EXPERT_SERVICES_YML = `suborgteams:
+ - expert-services-developers
+
+rulesets:
+ - name: Protect release and production branches
+ target: branch
+ enforcement: active
+ conditions:
+ ref_name:
+ include:
+ - refs/heads/release/*
+ - refs/heads/production
+ exclude: []
+ bypass_actors:
+ - actor_type: OrganizationAdmin
+ bypass_mode: always
+ rules:
+ - type: creation
+ - type: pull_request
+ parameters:
+ required_approving_review_count: 1
+ dismiss_stale_reviews_on_push: false
+ require_code_owner_review: false
+ require_last_push_approval: false
+ required_review_thread_resolution: false
+ allowed_merge_methods:
+ - merge
+ - squash
+ - rebase
+ required_reviewers:
+ - minimum_approvals: 1
+ file_patterns:
+ - "*.js"
+ reviewer:
+ id: 11721733
+ type: Team
+`
+
+const REPO_DEMO_SERVICE1_ARCHIVED_YML = `# Safe-Settings Configuration
+repository:
+ name: demo-repo-service1
+ description: "Repository 2 sample"
+ visibility: private
+ default_branch: main
+ homepage: ""
+ auto_init: true
+ force_create: true
+ delete_branch_on_merge: true
+ archived: true
+`
+
+const REPO_DEMO_SERVICE2_YML = `# Safe-Settings Configuration
+repository:
+ name: demo-repo-service2
+ description: "Repository 2 sample"
+ visibility: private
+ default_branch: main
+ homepage: ""
+ auto_init: true
+ force_create: true
+ delete_branch_on_merge: true
+ archived: false
+ topics:
+ - topic1
+ - topic2
+
+teams:
+ - name: expert-services-developers
+ permission: push
+`
+
+const SETTINGS_YML_ORG = `# Org-level safe-settings configuration
+
+rulesets:
+ - name: test
+ target: repository
+ source_type: Organization
+ source: ${ORG}
+ enforcement: disabled
+ conditions:
+ repository_property:
+ exclude: []
+ include:
+ - name: visibility
+ source: system
+ property_values:
+ - internal
+ rules:
+ - type: repository_delete
+
+custom_repository_roles:
+ - name: security-engineer
+ description: Can contribute code and manage the security pipeline
+ base_role: maintain
+ permissions:
+ - delete_alerts_code_scanning
+`
+
+// ─── Test Phases ─────────────────────────────────────────────────────────────
+
+async function setup () {
+ logPhase('Phase 0: Setup')
+
+ log('Cleaning up test repos...')
+ for (const repo of TEST_REPOS) { await deleteRepo(ORG, repo) }
+
+ log('Initializing admin repo with empty settings...')
+ const defaultBranch = await getDefaultBranch()
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, '# empty\n', defaultBranch, 'Initialize empty settings.yml for smoke test')
+
+ log('Cleaning up repos/ and suborgs/ directories...')
+ await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos`)
+ await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs`)
+
+ startSafeSettings()
+ log('Waiting for safe-settings to initialize...')
+ await sleep(15000)
+ log('Setup complete')
+}
+
+async function phase1CreateRepo () {
+ logPhase('Phase 1: Create test repo via test.yml')
+ const branch = 'smoke-test-phase1'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ log('Created branch: ' + branch)
+
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML, branch, 'Add test repo config')
+ log('Added test.yml to branch')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add test repo', branch, defaultBranch)
+
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ // Validate repo
+ const repo = await poll(async () => {
+ try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'test' })).data } catch { return null }
+ }, { desc: 'repo test to be created' })
+
+ assert(repo !== null, 'Repo "test" was created')
+ if (repo) {
+ assert(repo.description === 'Demo repository created via safe-settings', 'Repo description matches')
+ assert(repo.private === true, 'Repo is private')
+ assert(repo.has_issues === true, 'has_issues enabled')
+ assert(repo.has_projects === false, 'has_projects disabled')
+ assert(repo.has_wiki === false, 'has_wiki disabled')
+ assert(repo.delete_branch_on_merge === true, 'delete_branch_on_merge is true')
+ assert(repo.allow_squash_merge === true, 'allow_squash_merge is true')
+ assert(repo.allow_merge_commit === false, 'allow_merge_commit is false')
+ assert(repo.allow_rebase_merge === true, 'allow_rebase_merge is true')
+ }
+
+ // Validate team (poll — safe-settings may still be processing)
+ const esTeam = await poll(async () => {
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'test' })
+ return teams.find(t => t.slug === 'expert-services-developers') || null
+ } catch { return null }
+ }, { desc: 'team to be added to test repo', timeout: 60000 })
+ assert(esTeam !== null, 'Team expert-services-developers added')
+ if (esTeam) assert(esTeam.permission === 'push', `Team has push permission (got: ${esTeam.permission})`)
+
+ // Validate custom properties (poll)
+ const propsOk = await poll(async () => {
+ try {
+ const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' })
+ const propList = Array.isArray(props) ? props : []
+ const ownership = propList.find(p => p.property_name === 'ent-ownership')
+ const supervisory = propList.find(p => p.property_name === 'ent-supervisory-org')
+ return (ownership && ownership.value === 'expert-services' && supervisory && supervisory.value === 'expert-services') || null
+ } catch { return null }
+ }, { desc: 'custom properties to be set', timeout: 60000 })
+ assert(propsOk, 'Custom properties ent-ownership and ent-supervisory-org set')
+
+ // Validate rulesets (poll)
+ const rulesetsOk = await poll(async () => {
+ try {
+ const { data: rulesets } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'test' })
+ const synk = rulesets.find(r => r.name === 'synk')
+ const sonar = rulesets.find(r => r.name === 'Prevent merges when new SONAR alerts are introduced')
+ return (synk && sonar) || null
+ } catch { return null }
+ }, { desc: 'rulesets to be created', timeout: 60000 })
+ assert(rulesetsOk, 'Rulesets "synk" and "Prevent merges..." created')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function phase2DriftTeam () {
+ logPhase('Phase 2: Drift remediation - Team removal')
+
+ // Use gh CLI with user PAT so the event sender is a Human, not Bot
+ log('Removing expert-services-developers from test repo (as user)...')
+ if (!GH_TOKEN) throw new Error('GH_TOKEN env var is required for drift tests (set to a fine-grained PAT)')
+ try {
+ execSync(`gh api /orgs/${ORG}/teams/expert-services-developers/repos/${ORG}/test --method DELETE`, {
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
+ })
+ } catch (e) { logFail(`Could not remove team: ${e.message}`); return }
+
+ log('Waiting for safe-settings to remediate...')
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const team = await poll(async () => {
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'test' })
+ return teams.find(t => t.slug === 'expert-services-developers') || null
+ } catch { return null }
+ }, { desc: 'team to be re-added', timeout: 60000 })
+
+ assert(team !== null, 'Team re-added after drift')
+}
+
+async function phase3DriftRuleset () {
+ logPhase('Phase 3: Drift remediation - Rogue ruleset')
+
+ // Use gh CLI with user PAT so the event sender is a Human, not Bot
+ log('Creating rogue ruleset on test repo (as user)...')
+ const body = JSON.stringify({
+ name: 'rogue-ruleset', target: 'branch', enforcement: 'active',
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
+ rules: [{ type: 'deletion' }]
+ })
+ try {
+ execSync(`gh api /repos/${ORG}/test/rulesets --method POST --input -`, {
+ encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe']
+ })
+ } catch (e) { logFail(`Could not create rogue ruleset: ${e.message}`); return }
+
+ log('Waiting for safe-settings to remove rogue ruleset...')
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const removed = await poll(async () => {
+ try {
+ const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'test' })
+ return !rs.find(r => r.name === 'rogue-ruleset')
+ } catch { return false }
+ }, { desc: 'rogue ruleset to be removed', timeout: 90000 })
+
+ assert(removed, 'Rogue ruleset removed by safe-settings')
+}
+
+async function phase4DemoRepo1 () {
+ logPhase('Phase 4: Create demo-repo-service1')
+ const branch = 'smoke-test-phase4'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service1.yml`, REPO_DEMO_SERVICE1_YML, branch, 'Add demo-repo-service1 config')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add demo-repo-service1', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const repo = await poll(async () => {
+ try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service1' })).data } catch { return null }
+ }, { desc: 'demo-repo-service1 to be created' })
+
+ assert(repo !== null, 'Repo "demo-repo-service1" created')
+ if (repo) {
+ assert(repo.description === 'Repository 2 sample', 'Description matches')
+ assert(repo.private === true, 'Repo is private')
+ assert(repo.archived === false, 'Repo is not archived')
+ }
+
+ const teamsOk = await poll(async () => {
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service1' })
+ const t1 = teams.find(t => t.slug === 'ad-grp-payments-platform-owners')
+ const t2 = teams.find(t => t.slug === 'awesometeam-a-approvers')
+ const t3 = teams.find(t => t.slug === 'expert-services-developers')
+ return (t1 && t2 && t3) ? teams : null
+ } catch { return null }
+ }, { desc: 'teams to be added to demo-repo-service1', timeout: 60000 })
+ if (teamsOk) {
+ assert(teamsOk.find(t => t.slug === 'ad-grp-payments-platform-owners') !== undefined, 'Team AD-GRP-PAYMENTS-PLATFORM-OWNERS added')
+ assert(teamsOk.find(t => t.slug === 'awesometeam-a-approvers') !== undefined, 'Team awesometeam-a-approvers added')
+ assert(teamsOk.find(t => t.slug === 'expert-services-developers') !== undefined, 'Team expert-services-developers added')
+ } else { logFail('Teams not added to demo-repo-service1 in time') }
+
+ const topicsOk = await poll(async () => {
+ try {
+ const { data: topics } = await octokit.rest.repos.getAllTopics({ owner: ORG, repo: 'demo-repo-service1' })
+ return (topics.names.includes('topic1') && topics.names.includes('topic2')) ? topics : null
+ } catch { return null }
+ }, { desc: 'topics to be set on demo-repo-service1', timeout: 120000 })
+ assert(topicsOk, 'Topics topic1 and topic2 set')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function phase5Suborg () {
+ logPhase('Phase 5: Create suborg config')
+ const branch = 'smoke-test-phase5'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_YML, branch, 'Add expert-services suborg config')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add expert-services suborg', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ log('Checking suborg ruleset on demo-repo-service1...')
+ const ruleset = await poll(async () => {
+ try {
+ const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'demo-repo-service1' })
+ return rs.find(r => r.name === 'Protect release and production branches') || null
+ } catch { return null }
+ }, { desc: 'suborg ruleset on demo-repo-service1', timeout: 60000 })
+
+ assert(ruleset !== null, 'Suborg ruleset applied to demo-repo-service1')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function phase6Archive () {
+ logPhase('Phase 6: Archive demo-repo-service1')
+ const branch = 'smoke-test-phase6'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service1.yml`, REPO_DEMO_SERVICE1_ARCHIVED_YML, branch, 'Archive demo-repo-service1')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: archive demo-repo-service1', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const repo = await poll(async () => {
+ try {
+ const { data } = await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service1' })
+ return data.archived ? data : null
+ } catch { return null }
+ }, { desc: 'demo-repo-service1 to be archived' })
+
+ assert(repo !== null && repo.archived === true, 'Repo demo-repo-service1 is archived')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function phase7DemoRepo2 () {
+ logPhase('Phase 7: Create demo-repo-service2')
+ const branch = 'smoke-test-phase7'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service2.yml`, REPO_DEMO_SERVICE2_YML, branch, 'Add demo-repo-service2 config')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add demo-repo-service2', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const repo = await poll(async () => {
+ try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service2' })).data } catch { return null }
+ }, { desc: 'demo-repo-service2 to be created' })
+
+ assert(repo !== null, 'Repo "demo-repo-service2" created')
+ if (repo) {
+ assert(repo.archived === false, 'Repo is not archived')
+ assert(repo.private === true, 'Repo is private')
+ }
+
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service2' })
+ assert(teams.find(t => t.slug === 'expert-services-developers') !== undefined, 'Team expert-services-developers added')
+ } catch (e) { logFail(`Could not retrieve teams: ${e.message}`) }
+
+ log('Checking suborg ruleset on demo-repo-service2...')
+ const ruleset = await poll(async () => {
+ try {
+ const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'demo-repo-service2' })
+ return rs.find(r => r.name === 'Protect release and production branches') || null
+ } catch { return null }
+ }, { desc: 'suborg ruleset on demo-repo-service2', timeout: 60000 })
+
+ assert(ruleset !== null, 'Suborg ruleset applied to demo-repo-service2')
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function phase8OrgSettings () {
+ logPhase('Phase 8: Org-level settings')
+ const branch = 'smoke-test-phase8'
+ const defaultBranch = await getDefaultBranch()
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_ORG, branch, 'Add org-level settings')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: org-level settings', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ log('Merging PR...')
+ await mergePR(ORG, ADMIN_REPO, pr.number)
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ log('Checking custom repository roles...')
+ const role = await poll(async () => {
+ try {
+ const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org: ORG })
+ return (data.custom_roles || []).find(r => r.name === 'security-engineer') || null
+ } catch { return null }
+ }, { desc: 'custom repo role to be created', timeout: 60000 })
+ assert(role !== null, 'Custom repository role "security-engineer" created')
+
+ log('Checking org rulesets...')
+ const orgRuleset = await poll(async () => {
+ try {
+ const { data: rs } = await octokit.request('GET /orgs/{org}/rulesets', { org: ORG })
+ return rs.find(r => r.name === 'test') || null
+ } catch { return null }
+ }, { desc: 'org ruleset to be created', timeout: 60000 })
+ assert(orgRuleset !== null, 'Org ruleset "test" created')
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
+async function teardown () {
+ logPhase('Phase 9: Teardown')
+
+ stopSafeSettings()
+
+ log('Deleting test repos...')
+ try { await octokit.rest.repos.update({ owner: ORG, repo: 'demo-repo-service1', archived: false }) } catch { /* ok */ }
+ for (const repo of TEST_REPOS) { await deleteRepo(ORG, repo) }
+
+ log('Deleting test teams...')
+ for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) }
+
+ log('Deleting custom repository role...')
+ try {
+ const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org: ORG })
+ const secRole = (data.custom_roles || []).find(r => r.name === 'security-engineer')
+ if (secRole) await octokit.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', { org: ORG, role_id: secRole.id })
+ } catch { /* ok */ }
+
+ log('Deleting org rulesets...')
+ try {
+ const { data: rs } = await octokit.request('GET /orgs/{org}/rulesets', { org: ORG })
+ const testRs = rs.find(r => r.name === 'test')
+ if (testRs) await octokit.request('DELETE /orgs/{org}/rulesets/{ruleset_id}', { org: ORG, ruleset_id: testRs.id })
+ } catch { /* ok */ }
+
+ log('Resetting admin repo settings...')
+ const defaultBranch = await getDefaultBranch()
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, '# empty\n', defaultBranch, 'Reset settings.yml after smoke test')
+ await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos`)
+ await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs`)
+
+ log('Teardown complete')
+}
+
+// ─── Main ────────────────────────────────────────────────────────────────────
+
+async function main () {
+ const { App } = await import('octokit')
+ const app = new App({ appId: APP_ID, privateKey: PRIVATE_KEY })
+
+ // Find installation for our org
+ let installationId
+ for await (const { installation } of app.eachInstallation.iterator()) {
+ if (installation.account && installation.account.login.toLowerCase() === ORG.toLowerCase()) {
+ installationId = installation.id
+ break
+ }
+ }
+ if (!installationId) throw new Error(`No installation found for org ${ORG}`)
+
+ octokit = await app.getInstallationOctokit(installationId)
+ log('Authenticated as GitHub App installation')
+
+ console.log(`
+\x1b[36m╔══════════════════════════════════════╗
+║ Safe-Settings Smoke Test ║
+║ Org: ${ORG.padEnd(28)}║
+║ Admin Repo: ${ADMIN_REPO.padEnd(22)}║
+╚══════════════════════════════════════╝\x1b[0m
+`)
+
+ try {
+ await setup()
+ await phase1CreateRepo()
+ await phase2DriftTeam()
+ await phase3DriftRuleset()
+ await phase4DemoRepo1()
+ await phase5Suborg()
+ await phase6Archive()
+ await phase7DemoRepo2()
+ await phase8OrgSettings()
+ } catch (err) {
+ console.error(`\x1b[31mFatal error: ${err.message}\x1b[0m`)
+ console.error(err.stack)
+ } finally {
+ await teardown()
+ }
+
+ console.log(`
+\x1b[36m╔══════════════════════════════════════╗
+║ Results ║
+╚══════════════════════════════════════╝\x1b[0m
+ \x1b[32mPassed: ${passCount}\x1b[0m
+ \x1b[31mFailed: ${failCount}\x1b[0m
+`)
+
+ if (failures.length > 0) {
+ console.log('\x1b[31mFailures:\x1b[0m')
+ failures.forEach((f, i) => console.log(` ${i + 1}. ${f}`))
+ console.log()
+ }
+
+ process.exit(failCount > 0 ? 1 : 0)
+}
+
+main().catch(err => {
+ console.error(err)
+ stopSafeSettings()
+ process.exit(1)
+})
diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js
index 39aac216d..23d8ef84d 100644
--- a/test/unit/lib/settings.test.js
+++ b/test/unit/lib/settings.test.js
@@ -458,4 +458,185 @@ repository:
);
});
});
+
+ describe('getAllMatchingSubOrgSources', () => {
+ it('returns an empty set when subOrgConfigs is undefined', () => {
+ const settings = createSettings({})
+ settings.subOrgConfigs = undefined
+ const result = settings.getAllMatchingSubOrgSources('any-repo')
+ expect(result).toBeInstanceOf(Set)
+ expect(result.size).toBe(0)
+ })
+
+ it('returns an empty set when no suborg matches', () => {
+ const settings = createSettings({})
+ settings.subOrgConfigs = {
+ 'frontend-*': { source: '.github/suborgs/frontend.yml' }
+ }
+ const result = settings.getAllMatchingSubOrgSources('backend-repo')
+ expect(result.size).toBe(0)
+ })
+
+ it('returns a single-entry set when one suborg glob matches', () => {
+ const settings = createSettings({})
+ settings.subOrgConfigs = {
+ 'frontend-*': { source: '.github/suborgs/frontend.yml' },
+ 'backend-*': { source: '.github/suborgs/backend.yml' }
+ }
+ const result = settings.getAllMatchingSubOrgSources('frontend-app')
+ expect(result.size).toBe(1)
+ expect(result.has('.github/suborgs/frontend.yml')).toBe(true)
+ })
+
+ it('does not alter getSubOrgConfig single-match behavior', () => {
+ const settings = createSettings({})
+ settings.subOrgConfigs = {
+ 'frontend-*': { source: '.github/suborgs/frontend.yml', tag: 'A' }
+ }
+ const before = settings.getSubOrgConfig('frontend-app')
+ settings.getAllMatchingSubOrgSources('frontend-app')
+ const after = settings.getSubOrgConfig('frontend-app')
+ expect(after).toBe(before)
+ expect(after.tag).toBe('A')
+ })
+ })
+
+ describe('shouldConsiderReevaluation', () => {
+ let settings
+ const repo = { owner: 'o', repo: 'foo' }
+ beforeEach(() => {
+ settings = createSettings({})
+ settings.repoConfigs = {}
+ })
+
+ describe('with changeSignals (preferred path)', () => {
+ it('returns true when teams plugin reported changes', () => {
+ expect(settings.shouldConsiderReevaluation(repo, null, { teamsChanged: true })).toBe(true)
+ })
+
+ it('returns true when custom_properties plugin reported changes', () => {
+ expect(settings.shouldConsiderReevaluation(repo, null, { propertiesChanged: true })).toBe(true)
+ })
+
+ it('returns true on repository rename', () => {
+ expect(settings.shouldConsiderReevaluation(repo, null, { renamed: true })).toBe(true)
+ })
+
+ it('returns true on repository create', () => {
+ expect(settings.shouldConsiderReevaluation(repo, null, { created: true })).toBe(true)
+ })
+
+ it('returns false when all change signals are false (steady state)', () => {
+ // Pre-existing team that is already on the repo -> diffable reports no
+ // changes -> we must NOT trigger a re-eval reload.
+ settings.repoConfigs = { 'foo.yml': { teams: [{ name: 'core' }] } }
+ const signals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false }
+ expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' }, signals)).toBe(false)
+ })
+ })
+
+ describe('without changeSignals (fallback)', () => {
+ it('returns false when there is no repo-yml entry', () => {
+ expect(settings.shouldConsiderReevaluation(repo, null)).toBe(false)
+ expect(settings.shouldConsiderReevaluation(repo, undefined)).toBe(false)
+ })
+
+ it('returns false when repo-yml has no teams/properties and no rename', () => {
+ settings.repoConfigs = { 'foo.yml': { repository: { name: 'foo' } } }
+ expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(false)
+ })
+
+ it('returns true when repo-yml has teams', () => {
+ settings.repoConfigs = { 'foo.yml': { teams: [{ name: 'core' }] } }
+ expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(true)
+ })
+
+ it('returns true when repo-yml has custom_properties', () => {
+ settings.repoConfigs = { 'foo.yaml': { custom_properties: [{ name: 'EDP', value: 'true' }] } }
+ expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(true)
+ })
+
+ it('returns true on rename via repo.oldname', () => {
+ expect(settings.shouldConsiderReevaluation({ owner: 'o', repo: 'new', oldname: 'old' }, null)).toBe(true)
+ })
+
+ it('returns true on rename via repoConfig.oldname', () => {
+ expect(settings.shouldConsiderReevaluation(repo, { name: 'new', oldname: 'old' })).toBe(true)
+ })
+ })
+ })
+
+ describe('maybeReevaluateSuborg', () => {
+ it('is a no-op when reevaluateOnChange is false', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = false
+ settings.repoConfigs = { 'r.yml': { teams: [{ name: 'core' }] } }
+ const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set())
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op when repo-yml has no triggers (teams/properties/rename)', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ settings.repoConfigs = { 'r.yml': { repository: { name: 'r' } } }
+ const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set())
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op when changeSignals report no plugin changes (preexisting team)', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ // repo-yml has teams, but plugin reported no change (team already on repo)
+ settings.repoConfigs = { 'r.yml': { teams: [{ name: 'core' }] } }
+ const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue()
+ const signals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false }
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set(), signals)
+ expect(reloadSpy).not.toHaveBeenCalled()
+ expect(updateSpy).not.toHaveBeenCalled()
+ })
+
+ it('stops when the matched suborg source set is stable (no new sources)', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ settings.subOrgConfigs = { 'r*': { source: '.github/suborgs/x.yml' } }
+ const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue()
+ jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ // pre = post = {x.yml} -> stable, no recursion
+ const pre = new Set(['.github/suborgs/x.yml'])
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { teamsChanged: true })
+ expect(updateSpy).not.toHaveBeenCalled()
+ })
+
+ it('recurses once when a new suborg source appears, then stops at depth cap', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ // After reload, a new suborg matches r1
+ settings.subOrgConfigs = { 'r*': { source: '.github/suborgs/new.yml' } }
+ settings.repoConfigs = { 'r1.yml': { teams: [{ name: 't' }] } }
+ jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ jest.spyOn(settings, 'getRepoConfigs').mockResolvedValue({ 'r1.yml': { teams: [{ name: 't' }] } })
+ const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue()
+ const pre = new Set() // pre-apply: nothing matched
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { teamsChanged: true })
+ expect(updateSpy).toHaveBeenCalledTimes(1)
+ expect(settings.reevaluationDepth.get('r1')).toBe(1)
+ })
+
+ it('respects MAX_REEVALUATION_DEPTH and logs a warning', async () => {
+ const settings = createSettings({})
+ settings.reevaluateOnChange = true
+ settings.reevaluationDepth.set('r1', 1) // already at cap
+ settings.repoConfigs = { 'r1.yml': { teams: [{ name: 't' }] } }
+ stubContext.log.warn = jest.fn()
+ const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue()
+ const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue()
+ await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, new Set(), { teamsChanged: true })
+ expect(reloadSpy).not.toHaveBeenCalled()
+ expect(updateSpy).not.toHaveBeenCalled()
+ expect(stubContext.log.warn).toHaveBeenCalledWith(expect.stringContaining('max depth'))
+ })
+ })
}) // Settings Tests
From baaa9d5c2c0c0ec87e658d910b5cc61f46569919 Mon Sep 17 00:00:00 2001
From: Yadhav Jayaraman <57544838+decyjphr@users.noreply.github.com>
Date: Tue, 19 May 2026 14:19:41 -0400
Subject: [PATCH 11/17] Add external group linking functionality for teams and
update smoke tests
---
docs/github-settings/4. teams.md | 13 ++
lib/plugins/teams.js | 137 ++++++++++++++++++++
smoke-test.js | 127 ++++++++++++++++++-
test/unit/lib/plugins/teams.test.js | 186 ++++++++++++++++++++++++++++
4 files changed, 462 insertions(+), 1 deletion(-)
diff --git a/docs/github-settings/4. teams.md b/docs/github-settings/4. teams.md
index 496b30a32..56f1a4e86 100644
--- a/docs/github-settings/4. teams.md
+++ b/docs/github-settings/4. teams.md
@@ -48,5 +48,18 @@ teams:
permission: maintain
```
+
+
+
external_groupstring
+
Optional. The display name of an external IdP group (as listed under your organization's external groups) to link to the team. safe-settings looks up the group's id by display name via GET /orgs/{org}/external-groups and links the team via PATCH /orgs/{org}/teams/{team_slug}/external-groups. The link is reconciled on every sync and is idempotent (it skips the PATCH when the team is already linked to the same group). The external-groups list is fetched at most once per org per sync, only when at least one team entry uses this property. If the named group does not exist for the org, an error is logged and the team-repo association still applies.